* feat(quota-store): separate revoke and delete semantics for redemption codes
- PATCH /api/admin/quota-store/storage-codes/:code accepts { revokedAt } to
revoke a code (keeps it visible, prevents future redemptions)
- DELETE /api/admin/quota-store/storage-codes/:code removes eligible codes
- Added revokeStorageCodeSchema (shared) with zValidator on PATCH route
- Fixed revokeStorageRedemptionCode API fn to use PATCH with revokedAt payload
- Added deleteStorageRedemptionCode API fn using DELETE
- UI: separate Revoke and Delete buttons per code row with distinct disabled rules
- Revoke disabled when already revoked or fully redeemed
- Delete disabled when any redemptions exist (usesCount > 0)
- Updated confirmation copy to describe product outcome, not technical action
- i18n: added delete keys in en.json and zh.json; updated revokeConfirm copy
- Updated integration, unit, and component tests
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: align storage code revoke contract
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds ZPan admin quota-store APIs and UI for Cloud-bound storage redemption code management, refactors the admin quota-store page into tabs, and polishes the user store page.
* feat: expand audit event coverage across all state-changing operations
Add audit events for share lifecycle, object operations, team management,
admin/system mutations, storage, quotas, invite codes, site invitations,
users, licensing, and branding.
New events recorded:
- Share: share_create, share_revoke
- Object: upload_confirm, upload_cancel, object_copy, object_purge,
batch_trash, batch_purge, trash_empty
- Team/org: team_invite_link_create, team_member_join, team_logo_update,
team_logo_delete
- System: system_option_set, system_option_delete
- Storage: storage_create, storage_update, storage_delete
- Quota: quota_update
- Invite codes: invite_code_generate, invite_code_delete
- Site invitations: site_invitation_create, site_invitation_revoke
- Users: user_disable, user_enable, user_delete
- Licensing: license_pair, license_disconnect
- Branding: branding_update, branding_reset
Auth sign-up/sign-in events: not logged. The Better Auth hook lifecycle
does not provide reliable org context at sign-up (personal org is created
after the user.create.after hook), and sign-in cannot be distinguished
from normal session refresh without brittle internal request parsing.
No schema migration required; existing activityEvents table fields
support all new event types.
Add i18n translations (en + zh) for all new action names and target types.
Add 26 integration tests verifying every new event category and asserting
no secrets are stored in metadata.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
* feat: add missing audit event coverage for share_download, org lifecycle, license_refresh
Fill gaps identified in code review:
1. share_download: record after successful file download via public share
endpoint (publicShares GET /:token/objects/:ref). Authenticated viewer
is used as actor; share creator is proxy for anonymous downloads with
anonymous:true in metadata. Presigned URL never stored.
2. team lifecycle via Better Auth organizationHooks (server/auth.ts):
- team_member_remove (afterRemoveMember): member.userId used as actor
since BA hook does not expose the initiator
- team_member_role_update (afterUpdateMemberRole): same limitation,
member.userId used; previousRole/newRole recorded in metadata
- team_settings_update (afterUpdateOrganization): actor user available
- team_delete (afterDeleteOrganization): actor user available
- team_member_join via email invite (afterAcceptInvitation): joins
existing link-based join coverage in teams.ts
3. license_refresh: record after POST /api/licensing/refresh succeeds
(licensing-admin.ts)
Unhookable events (documented with code references):
- invite_code_redeem: called at server/auth.ts:269 in user.create.after;
personal org (required for activityEvents.orgId NOT NULL) is created in
session.create.before which runs after this hook. Better Auth provides
no shared state between these hooks.
- site_invitation_accept: called at server/auth.ts:275 in user.create.after;
same orgId unavailability issue.
Add 7 new integration tests for: share_download (×2, authed + anonymous),
team_settings_update, team_member_remove, team_member_role_update,
team_delete, license_refresh.
Add i18n translations (en + zh) for new actions:
share_download, team_member_remove, team_member_role_update,
team_settings_update, team_delete, license_refresh.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add listAdminAuditEvents service with pagination and filters (orgId, userId, action, targetType)
- Add GET /api/admin/audit route protected by requireAdmin + requireFeature('audit_log')
- Mount admin audit route in app.ts and export AdminAuditRoute type
- Add AdminAuditEvent shared type with orgName field
- Add listAdminAuditQuerySchema to shared schemas
- Add adminAuditApi RPC client and listAdminAuditLogs API wrapper
- Replace placeholder /admin/audit page with real table/list UI using React Query
- Show Pro upgrade prompt when audit_log entitlement is unavailable
- Add empty, loading, error, and load-more states to UI
- Remove comingSoon from audit_log in feature registry
- Update en.json and zh.json with real audit log labels
- Add integration tests covering 401/403/402 guards, filters, pagination, and response shape
- Add unit tests for listAdminAuditLogs API wrapper
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add license-state.ts helper for reading/writing license state as
system_options key-value pairs instead of a dedicated singleton table
- Rewrite refresh.ts, has-feature.ts, entitlement.ts, licensing-admin.ts,
licensing-refresh-runner.ts to use license-state helpers
- Generate migration 0014 to drop license_binding table
- Update all 10 test files to use setLicenseOptions instead of
db.insert(licenseBinding)
- All 2809 tests pass
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comprehensive end-to-end test covering:
- Live cloud API contract (pairing create/poll, entitlement 401)
- Feature gates: community (unbound) → Pro features blocked
- Feature gates: Pro binding → all 4 features enabled
- Feature gates: expired cert → features revoked
- Feature gates: partial features (subset of Pro)
- Full lifecycle: pair → approve → activate → open_registration → unbind → blocked
- PASETO verification: reject unknown keys, verify PUBLIC_KEYS configured
- System options: 402 without Pro, 201 with Pro for open_registration
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Send refresh token as Authorization Bearer header (not JSON body)
- Read 'certificate' field from refresh response (was 'entitlement')
- Handle both PASETO tokens and legacy JSON in cached certificates
- Verify PASETO tokens in getPlanFromCert() for admin display
- Extract expires_at from snake_case entitlement in pairing poll
- Update all related tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat: v2.6 Z11 — prod public key, Docker cron docs, release notes
- Replace DEV placeholder in public-keys.ts with cloud.zpan.space
production Ed25519 key (k4.public.sphdaogcyIh2_6_yZnO4_xQsi2m52HH9j2CPHcKlGGw)
from cloud C5 cross-repo PR
- Add external cron section to docs/deploy/docker.md for the
POST /api/licensing/refresh-cron endpoint (Z6)
- Create docs/v2.6-release-notes.md with what's new, retroactive gate
notice (open_registration, teams_unlimited, team_quotas), upgrade guide
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
* test(licensing): decouple verify/entitlement tests from DEV secret key
Tests were hardcoded to the old DEV placeholder key. Now they generate
a fresh throwaway keypair per suite (beforeAll/afterAll), inject the
public key into PUBLIC_KEYS, and restore the original on teardown.
This keeps the tests independent of whichever production key is in
PUBLIC_KEYS, so rotating the key never breaks the test suite.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
---------
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- server/routes/quotas.ts: gate PUT /:orgId with requireFeature('team_quotas') → 402 on Community
- server/services/matter.ts: add teamQuotaEnabled param to incrementUsageIfAllowed and confirmUpload; when false, skip per-team quota check but still track storage usage
- server/services/save-to-drive.ts: thread teamQuotaEnabled through saveShareToDrive → saveFile/saveFolderRecursive
- server/routes/objects.ts: check hasFeature('team_quotas') before confirmUpload
- server/routes/shares.ts: skip isQuotaSufficient pre-check and pass teamQuotaEnabled to saveShareToDriveService when not Pro
- src/routes/_authenticated/admin/users/index.tsx: hide quota column/button behind useEntitlement('team_quotas'); show UpgradeHint when not Pro
- server/test/setup.ts: add seedProLicense helper for integration tests
- Update affected integration tests to seed Pro license where quota enforcement is expected
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- Add COMMUNITY_TEAM_LIMIT=3 to shared/constants so both server and frontend share one source of truth
- Add server/services/team-count-guard.ts: countUserOrgs + checkTeamLimit (consults licensing state)
- Hook into better-auth organization.beforeCreateOrganization to throw 402 when limit is reached
- Update /teams UI: isAtLimit guard (with loading protection) shows ProBadge on "New Team" button and opens UpgradeHint dialog instead of create dialog when user is at limit
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
* feat: v2.6 Z7 white-label branding — logo, favicon, wordmark, hide footer
Implements the white-label branding feature gated by `white_label` Pro entitlement:
Backend:
- GET /api/branding (public) — returns BrandingConfig from systemOptions
- PUT /api/admin/branding (admin + requireFeature) — multipart upload for
logo/favicon files + wordmark_text/hide_powered_by fields
- DELETE /api/admin/branding/:field (admin + requireFeature) — resets one field
- server/services/branding.ts — S3 upload to _system/branding/, atomic
upsertOption via onConflictDoUpdate
Frontend:
- BrandingProvider wraps the app; fetches branding on boot, applies favicon via
<link>, sets --site-wordmark CSS var
- AppSidebar uses branding context: custom logo src, wordmark text, and
"Powered by ZPan" footer (hidden when hide_powered_by is set)
- Admin /branding page: shows UpgradeHint for non-Pro, upload form with live
preview panel for Pro users
- BrandingConfig and BrandingField types moved to shared/types/ per convention
Tests: integration tests for all auth guards (401/403/402), validation (415/422),
and happy paths; frontend api.test.ts covers getBranding, saveBranding,
resetBrandingField.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
* test: add S3 upload and validation coverage for branding routes
Add integration tests for logo/favicon file upload, MIME validation,
size limits, missing storage, and seeded branding values to bring
patch coverage above codecov threshold.
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
---------
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- Add server/services/signup-mode-guard.ts: getEffectiveSignupMode()
applies Pro check when stored mode is 'open'; non-Pro falls back to
invite-only so downgraded instances stay secure
- Update server/auth.ts: replace internal getSignupMode() with
getEffectiveSignupMode() from new service
- Update server/routes/system.ts: PUT auth_signup_mode=open returns 402
feature_not_available when open_registration feature is absent
- Update RegistrationModeSection: 'open' radio disabled with ProBadge
for non-Pro; clicking it opens UpgradeHint dialog instead of saving
- Add integration tests: 6-combination matrix (3 modes × 2 plans) plus
admin API guard tests
- Update auth.integration.test.ts: split open-mode tests for Pro/non-Pro
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- Add `licenseBinding` singleton table (id=1) to server/db/schema.ts
- Correct `0012_image-hosting` journal idx from 11→12 (matches its prefix;
was left wrong by previous rename-only fix in ef1fab8), then auto-generate
migration 0013_licensing.sql via drizzle-kit
- New 0013_snapshot.json created; 0012_snapshot.json unchanged
- Add ProFeatures enum to shared/constants.ts
- Add shared/types/licensing.ts with LicenseEntitlement, ProFeature, BindingState
- Export new types from shared/types/index.ts
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
On Cloudflare Workers deployments with PUBLIC_IMAGES (R2 binding) +
PUBLIC_IMAGES_URL (env) configured, writes go through the R2 binding
directly — zero auth overhead, zero egress cost, no AWS SDK in the hot
path. Reads are served straight from R2's managed public domain; no
Worker round-trip per image.
Non-CF deployments (Node/Docker/Lambda/Vercel/etc.) keep the existing
behavior: select the DB-configured mode='public' storage row and use
S3Service. The only requirement is that users still add a public
storage via Admin → Storages.
`uploadPublicImage(platform, prefix, id, file)` and
`deletePublicImageVariants(platform, prefix, id)` now take a Platform
rather than a bare Database. They internally pick the backend:
getBackend:
if getBinding('PUBLIC_IMAGES') && getEnv('PUBLIC_IMAGES_URL')
→ R2 backend
else if selectStorage(db, 'public') succeeds
→ S3 backend
else
→ none (returns 503)
The R2Bucket type is declared locally (minimal structural shape) so we
avoid pulling @cloudflare/workers-types into non-CF builds.
12 new unit tests in image-upload.test.ts cover both backends —
mime/size validation, URL construction (including trailing-slash
normalization + jpeg→jpg extension), delete-all-variants, and the
fallback precedence matrix (binding alone / URL alone / neither →
expected result).
Callers updated:
- server/routes/me.ts — PUT/DELETE /avatar
- server/routes/teams.ts — PUT/DELETE /:teamId/logo
Existing integration tests (54 cases across me + teams) continue to
pass via the S3 fallback path — they use mockPlatform without a binding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Platform-native bindings (Cloudflare R2/D1/KV, Azure Storage contexts,
etc.) are not representable as strings, so getEnv() can't carry them.
Add a typed getBinding<T>() accessor: returns the binding on platforms
that support it, undefined on others.
Callers branch on the return — e.g. \`getBinding<R2Bucket>('PUBLIC_IMAGES')\`
will be defined on CF and undefined on Node/Docker, letting the same
code pick a runtime-appropriate path without platform-specific imports.
Used in the next commit to switch the public image upload flow to R2
binding on CF (zero-auth, zero-egress) while keeping the S3 fallback
for every other platform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>