Centralize request audit recording, preserve immutable download-task history, and derive hourly statistics and backfills from the same authoritative sources. Add durable user registration facts so admin deletion no longer destroys signup history.
Retain purged matter and image metadata while hiding tombstones from live reads. Record exact storage deltas and build hourly trends only after the opening balance.
Build versioned UTC hourly rollups, validate analytics facts, and keep dashboard requests result-only with explicit coverage semantics.
Move derived metrics to the server and simplify the admin dashboard around one reconcilable UTC range without export.
* feat: make forcePathStyle configurable per storage
Previously hardcoded to true, which breaks S3-compatible backends that require
virtual-hosted-style addressing (e.g. Alibaba Cloud OSS). Now configurable via
admin storage settings with a toggle switch, defaulting to true for backwards
compatibility.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: cover storage force path style
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: saltbo <saltbo@foxmail.com>
* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode
Host user avatars and org logos on the ZPan Cloud avatar service
(zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the
now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3).
- image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against
a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size
(MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500;
unbound instance returns 503 cloud_required (delete is a best-effort no-op).
- licensing-cloud: createAvatarUploadClient builds the client with a plain-object
bearer header so both the image content-type and Authorization survive hono's
per-request header merge (a Headers instance would be dropped).
- drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer
takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and
the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring.
Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
* ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy
The Cloud avatar migration removed the PUBLIC_IMAGES binding from
wrangler.toml, so the deploy workflow's R2 public-images steps are dead and
must go too — otherwise every CF deploy keeps re-provisioning a public-read
zpan-public-images bucket (the footgun #456 eliminates) and sets an unused
PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and
secret steps (steps.r2 was only consumed by the secret step). Also drops a
stale storage-modes line from the v2.0 roadmap.
Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
---------
Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
Every endpoint now exposes one monomorphic schema: role/state changes field
values (mask / null / filter), never the shape.
image-hosting/config: drop the `full config | { enabled: false }` union. GET
always returns the full ImageHostingConfig shape carrying `enabled`; not-configured
→ `enabled: false` with every other field null (`createdAt` is now nullable).
`buildResponse` is made total over `row | null` so it is the single producer of
the shape, and `getIhostConfig` no longer returns `| null`.
auth-providers: collapse the admin-config vs public-display union into one
AuthProvider schema (providerId, type, enabled, name, icon, clientId, discoveryUrl,
scopes, clientSecret). Same endpoint, no path split — role changes one value:
admin gets a masked clientSecret, front-of-house gets `clientSecret: null` and the
enabled-only list. The two list usecases collapse into listAuthProviders(deps,
{ isAdmin }); the PUT response and the merged frontend wrapper adopt the same
schema, deleting AuthProviderConfig/PublicAuthProvider entirely.
Regenerated the Go client (union types removed) and updated frontend types/consumers.
Closes#449.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve#448 — one upload entry point and an AIP-164 soft delete.
Upload: POST /objects now returns size-decided upload instructions
{ sessionId, partSize, urls }; the server picks single PutObject (<=5 GiB)
vs 5 GiB-part multipart (>5 GiB) and rejects >5 TiB. The client PUTs each
slice, reads its ETag, then POSTs them to
POST /objects/{id}/uploads/{sid}/completions (returns the live object).
DELETE /objects/{id}/uploads/{sid} aborts and discards the draft.
Trash: matters.status drops 'trashed' (enum is {draft,active}); trash is
tracked by the existing trashedAt timestamp. DELETE /objects/{id} now
soft-deletes; the recycle bin lives under /trash/objects (list roots, get,
restorations, purge). Empty-trash is a frontend loop over roots.
BREAKING CHANGE:
- removes PUT /objects/{id}/status and POST /objects/{id}/uploads
- PUT .../uploads/{sid}/status -> POST .../uploads/{sid}/completions {parts}
- DELETE /objects/{id} flips hard-purge -> soft-delete; permanent purge
moves to DELETE /trash/objects/{id}
- DELETE /trash removed; restore is POST /trash/objects/{id}/restorations
- matters.status enum loses 'trashed' (migration backfills to trashedAt)
The migration swaps the matters_active_name_uniq partial index to exclude
trashed rows (WHERE status='active' AND trashed_at IS NULL). The single-PUT
presign is header-free so the uniform slice uploader's raw PUT matches the
S3 signature. Go downloader client + agent reworked to the unified flow.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(openapi): complete API coverage with truthful schemas + unified error handling
Migrate every resource router to `@hono/zod-openapi` so the global OpenAPI
document (and the SDKs generated from it) covers the whole product API, not just
~15% of it. The document now describes 25 resources with named component schemas,
operationIds, and accurate response shapes.
What changed:
- Unified error handling: a single `mapDomainError` (DownloadError, ObjectUpload-
SessionError, NameConflictError, StorageQuotaExceededError, BackgroundJobError,
WebDavPathError) wired into a global `app.onError`; handlers throw domain errors
instead of hand-rolling per-route try/catch. One shared `ErrorResponse` envelope.
- Shared http helpers (`server/http/openapi.ts`): generic `jsonContent`/`jsonBody`/
`errorResponse` so the precise schema type reaches `createRoute` — typing
`c.req.valid()` and strictly checking `c.json()` returns (no widened `z.ZodType`).
- Schemas are the truth: response schemas are named (`.openapi('X')`), wire-shaped
(ISO-string timestamps via per-resource `toXDTO` mappers where the domain type
uses `Date`), and strictly enforced against handler returns. The strict pass
surfaced and fixed several latent schema lies (e.g. transfer result shape,
download-task delete tombstone, object `purged`).
- operationId + summary on every route → clean SDK method names.
- Curated out of the public SDK (kept as plain routes): the `/r` redirect resolver,
store webhook receiver, internal telemetry endpoint, the PicGo/ShareX image
upload tool endpoint, the share download redirect, and cron-secret licensing
sync endpoints.
- Disambiguated user operationIds that collided with better-auth's admin API;
dropped `additionalProperties` schemas that oapi-codegen mis-generates.
- Regenerated the Go downloader client and realigned its hand-written wrapper to
the operationId-derived names.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(cmd): gofmt the realigned downloader client
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(downloads): block SSRF targets in remote-download source URL
The remote-download source URI was only length-validated, so an
authenticated editor could point a task at the cloud metadata endpoint,
loopback, or RFC 1918 hosts and have the response exfiltrated to their
own drive. Add a shared isSafeHttpUrl/isBlockedUrlHost guard (scheme
allowlist + private/loopback/link-local/metadata/IPv6 blocking) and
cross-check source type vs uri in createDownloadTaskSchema.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(api): cover 9 untested src/lib/api.ts wrappers
Adds api.test.ts coverage (RPC path, method, payload, success + ApiError
paths) for listObjectsByPath, isNameConflictError, listAdminAuthProviders,
upsertAuthProvider, deleteAuthProvider, listInviteCodes, generateInviteCodes,
deleteInviteCode, and listTeamActivities — satisfying the CLAUDE.md coverage
gate that otherwise blocks PRs touching api.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(spaces): release source quota on cross-space move
A cross-space "move" copied bytes into the target (reserving quota there)
but only trashed the source. Trashed files still count toward usage, so the
moved bytes were billed in both spaces and the source never freed — contrary
to the design doc ("copy + delete source, quota effectively transfers").
Purge the source subtree (independent S3 copy already exists in the target)
instead of trashing it, which deletes the objects, cascades share cleanup,
and reconciles usage. Rename the response field sourceTrashed -> sourceDeleted
and update the move hint copy accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(upload): wire S3 multipart for large files
The upload UI only ever did a single presigned PUT, which caps at S3's
5 GiB limit and fails the whole transfer on any network blip — despite a
complete multipart backend (object-upload-sessions) sitting unused.
Add uploadPartToS3 (PUTs a part, returns its ETag) and a multipart-upload
orchestrator: open session -> presign parts in batches of 100 -> PUT parts
with bounded concurrency and per-part retry -> complete. Files over 100 MiB
take this path; smaller files keep the single-PUT flow. Cancellation aborts
the multipart and the draft. Also fixes the presignObjectUploadParts wrapper
type to match the server's actual `url` field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(auth): add password-reset flow
There was no self-service password recovery — a forgotten password needed
admin intervention. SMTP/email sending was already built; this wires the
last mile: better-auth sendResetPassword (reset email), a "Forgot password?"
link on sign-in, and /forgot-password + /reset-password pages. The
forgot-password page never reveals whether an account exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(trash): auto-purge trashed items past a retention window
Trashed files counted toward quota forever — trash never auto-emptied, so
storage was never reclaimed without a manual "empty trash". Add a daily cron
(CF Workers 0 4 * * * + Node setInterval) that purges trashed items older than
ZPAN_TRASH_RETENTION_DAYS (default 30, 0 disables) across all orgs, reusing the
existing purge path so S3 objects, share references, and quota are all cleaned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(notifications): typed NotificationType, i18n rendering, team-join
Notifications were a bare-string type with only 3 producers, and server copy
was stored as hardcoded English (zh users saw English).
- Add a NotificationType union in shared/ and type the notification service.
- Render notification title/body client-side from type + metadata via i18n,
falling back to stored strings for older rows (fixes the hardcoded-English gap).
- Notify users when they join a team (team_join).
(Login auditing was intentionally dropped: reusing the activity-events feed for
sign_in events would spam every user's per-org activity timeline. Proper auth
auditing belongs in a dedicated log and can be added separately.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: cover SSRF guard and multipart upload branches
Raise patch coverage on the new code: uploadPartToS3 pre-aborted-signal and
network-error paths, the url-safety octet-overflow and public-IPv6 branches,
and the invalid-magnet rejection in the download-task schema.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(storage): type S3Service against a narrow credentials shape
The hand-written shared Storage type had a phantom `uid` field and lacked
`filePath`, diverging from the DB row, so 44 call sites bridged the gap with
`as unknown as S3Storage`. Introduce S3StorageCredentials (the 6 fields the S3
client actually reads); DB storage rows satisfy it structurally, so all casts
are gone. Fix the shared Storage type to match the real API response (drop uid,
add filePath, nullable customHost).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(storage): dedupe fileExt into path-template
fileExt() was defined byte-identically in objects.ts, webdav.ts, and
save-to-drive.ts, all feeding buildObjectKey. Move it next to buildObjectKey
in path-template.ts and import it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(deps): remove 10 unused packages and the trash format-utils shim
After migrating to the unified radix-ui package the individual @radix-ui/react-*
packages (avatar, dialog, dropdown-menu, label, separator, slot, tooltip) were
orphaned, along with @dnd-kit/sortable, @dnd-kit/utilities (only @dnd-kit/core
is used), and @opentelemetry/api (transitive via better-auth, not imported
directly). Also delete src/components/trash/format-utils.ts — a pure re-export
of @/lib/format whose only consumer was its own test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(ui): dedupe getInitials into @/lib/format
getInitials was reimplemented in 6 components/routes (user menu, org switcher,
share layout, profile, users list, team settings) — behaviorally identical to
the canonical @/lib/format.getInitials already used by the admin pages. Replace
all locals with the shared import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(traffic): extract consumeAndReportDownloadTraffic
The consume-quota (422) -> report-egress (402, refund) preamble was hand-rolled
in the object, landing-share, direct-share, and WebDAV download paths. Extract
consumeAndReportDownloadTraffic, parameterizing the 422 renderer (JSON vs text)
and the compensating action (share download-counter decrement). The image-host
redirect path reports after presigning, so it keeps its own sequence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(downloads): block SSRF targets in remote-download source URL
The remote-download source URI was only length-validated, so an
authenticated editor could point a task at the cloud metadata endpoint,
loopback, or RFC 1918 hosts and have the response exfiltrated to their
own drive. Add a shared isSafeHttpUrl/isBlockedUrlHost guard (scheme
allowlist + private/loopback/link-local/metadata/IPv6 blocking) and
cross-check source type vs uri in createDownloadTaskSchema.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(api): cover 9 untested src/lib/api.ts wrappers
Adds api.test.ts coverage (RPC path, method, payload, success + ApiError
paths) for listObjectsByPath, isNameConflictError, listAdminAuthProviders,
upsertAuthProvider, deleteAuthProvider, listInviteCodes, generateInviteCodes,
deleteInviteCode, and listTeamActivities — satisfying the CLAUDE.md coverage
gate that otherwise blocks PRs touching api.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(spaces): release source quota on cross-space move
A cross-space "move" copied bytes into the target (reserving quota there)
but only trashed the source. Trashed files still count toward usage, so the
moved bytes were billed in both spaces and the source never freed — contrary
to the design doc ("copy + delete source, quota effectively transfers").
Purge the source subtree (independent S3 copy already exists in the target)
instead of trashing it, which deletes the objects, cascades share cleanup,
and reconciles usage. Rename the response field sourceTrashed -> sourceDeleted
and update the move hint copy accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(upload): wire S3 multipart for large files
The upload UI only ever did a single presigned PUT, which caps at S3's
5 GiB limit and fails the whole transfer on any network blip — despite a
complete multipart backend (object-upload-sessions) sitting unused.
Add uploadPartToS3 (PUTs a part, returns its ETag) and a multipart-upload
orchestrator: open session -> presign parts in batches of 100 -> PUT parts
with bounded concurrency and per-part retry -> complete. Files over 100 MiB
take this path; smaller files keep the single-PUT flow. Cancellation aborts
the multipart and the draft. Also fixes the presignObjectUploadParts wrapper
type to match the server's actual `url` field.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(auth): add password-reset flow
There was no self-service password recovery — a forgotten password needed
admin intervention. SMTP/email sending was already built; this wires the
last mile: better-auth sendResetPassword (reset email), a "Forgot password?"
link on sign-in, and /forgot-password + /reset-password pages. The
forgot-password page never reveals whether an account exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(trash): auto-purge trashed items past a retention window
Trashed files counted toward quota forever — trash never auto-emptied, so
storage was never reclaimed without a manual "empty trash". Add a daily cron
(CF Workers 0 4 * * * + Node setInterval) that purges trashed items older than
ZPAN_TRASH_RETENTION_DAYS (default 30, 0 disables) across all orgs, reusing the
existing purge path so S3 objects, share references, and quota are all cleaned.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(notifications): typed NotificationType, i18n rendering, team-join
Notifications were a bare-string type with only 3 producers, and server copy
was stored as hardcoded English (zh users saw English).
- Add a NotificationType union in shared/ and type the notification service.
- Render notification title/body client-side from type + metadata via i18n,
falling back to stored strings for older rows (fixes the hardcoded-English gap).
- Notify users when they join a team (team_join).
(Login auditing was intentionally dropped: reusing the activity-events feed for
sign_in events would spam every user's per-org activity timeline. Proper auth
auditing belongs in a dedicated log and can be added separately.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: cover SSRF guard and multipart upload branches
Raise patch coverage on the new code: uploadPartToS3 pre-aborted-signal and
network-error paths, the url-safety octet-overflow and public-IPv6 branches,
and the invalid-magnet rejection in the download-task schema.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs: add spaces/quota/sharing design decisions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(store): require team owner role for billing and purchase endpoints
Team orgs now gate checkout, billing portal, credits (balance/ledger/
redemptions), and order management behind the owner role. Personal orgs
are unaffected. Implements docs/design/spaces-quota-sharing.md §2.1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(store): clarify purchase target and gate billing UI to owners
The store page now states which space purchases fund, hides purchase
and billing surfaces from non-owner team members with guidance to
contact the owner, and labels team orders with the team name on the
cloud side. Implements docs/design/spaces-quota-sharing.md §2.1.2-3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(objects): cross-space copy/move with file manager entry
Adds POST /api/objects/:id/transfers (copy or move a file/folder into
another space) reusing the save-to-drive copy engine; move = copy +
trash source, and the source survives any partial copy. The file
manager gains a 'Copy/Move to space' action with a space/folder picker.
Also fixes a privilege hole: save-to-drive (and the new transfer
endpoint) previously accepted any personal org as a write target,
allowing writes into other users' personal spaces; targets are now
restricted to orgs with editor access or the caller's own personal
org. The transfer folder picker also fixes the save-to-drive dialog
listing the active org's folders instead of the selected target's.
Implements docs/design/spaces-quota-sharing.md §3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(admin): per-team quota management and default team quota
Admins can now grant, edit, and revoke storage entitlements for any
space (team or personal) via /api/admin/quotas/:orgId/entitlements and
a new admin Quotas page. New teams take their initial quota from the
default_team_quota system option (falling back to default_org_quota),
configurable in admin settings. Completes the v2.2 roadmap item
'Per-team storage quota set by admin'; implements design doc §2.3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(quotas): allocate purchased storage packs between owned spaces
Space owners can move whole one-time purchased packs (cloud_order
grants) between spaces they own via
POST /api/quotas/me/entitlements/:id/transfers. Plans and admin grants
are not transferable, and a transfer is blocked when the source space's
usage would exceed its remaining quota. The storage page lists the
current space's packs with a move dialog. Implements design doc §2.2.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(shares): received shares inbox on the shares page
GET /api/shares?box=received lists active shares addressed to the
current user (matched by user id or the email the share targeted),
with the sharer's name. The shares page gains a sent/received toggle;
received items open the share landing page. This is an inbox of share
links, not a mounted filesystem (design doc §4.4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(hooks): cover default team quota in site options hook
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(store,admin): drop purchase-target banner; scope admin quotas page to teams
The storage page no longer shows the 'purchases fund X space' line —
owners see the store normally and non-owner members keep the guidance
notice. The admin Quotas page now lists team spaces only; personal
quotas stay on the user detail page, removing the overlapping entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* revert(quotas): remove storage pack allocation
Allocation (§5.5) operated on an empty set: the store only sells
per-workspace subscriptions, and the original design restricted moves
to one-time cloud packs, which don't exist in the catalog. Subscriptions
can't be safely allocated either — the webhook cancellation/downgrade
path matches on the original targetOrgId, so a moved entitlement's
claw-back silently fails and leaves ghost capacity. The feature is also
redundant: family owners subscribe the team space directly (§2.1) and
self-hosted admins grant capacity straight to it (§2.3).
Removes the quota-allocation service, /api/quotas/me/entitlements
endpoints, the storage-page packs panel, isOrgOwner helper, and related
tests/i18n. Design doc §2.2 updated to record why it was deferred and
the prerequisite (a one-time pack SKU) for revisiting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(admin): replace quotas page with Teams management (list + detail)
Promotes the admin quota surface to a proper Teams section, sibling to
Users: a team list page where each row opens a team detail page, and the
detail page manages quota entitlements (grant/edit/revoke) — mirroring
the user detail page. All backed by org data.
- New /api/admin/teams (list + detail) with member count, owner, and
effective storage usage; teams identified by non-personal slug so
legacy teams with null metadata are included.
- New /admin/teams list + /admin/teams/$orgId detail routes; the old
/admin/quotas page is removed and the nav item becomes 'Teams'.
- Entitlement CRUD continues to reuse the org-generic
/api/admin/quotas/:orgId/entitlements endpoints (invisible to users).
- Personal-space quotas remain on the user detail page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(admin): use a distinct icon for the Teams nav item
Users and Teams both used people icons (Users / UsersRound) and were
hard to tell apart in the sidebar. Teams now uses Building2 (org).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(admin): chunk team member/owner IN-lists under D1's param cap
listTeams bound all team orgIds into single member-count and owner-name
queries; on D1 (100 bound-param cap) that breaks past ~100 teams.
getEffectiveQuotasByOrg already chunks at 90 — match it for the two new
queries. Members of a given org all land in one chunk, so per-org owner
ordering is preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Flatten the instance `runtime` object into two fields: `runtime` (the JS engine,
node | workerd) and `platform` (the deployment host). The About page shows each
as its own row with friendly labels (e.g. "workerd" + "Cloudflare Workers",
"Node.js" + "Docker").
- Detect the platform from the entry file (entry === target): each serverless
entry declares it; entry-node sniffs Cloud Run (K_SERVICE) / Docker
(ZPAN_RUNTIME, set in the Dockerfile) / bare node. Cloudflare is detected from
the D1 binding.
- Decouple the cloud payload: zpan-cloud-sdk fixes runtime { provider, target },
so CloudInstanceInfo keeps that shape and buildCloudInstanceInfo maps to it;
buildInstanceInfo serves the richer flat shape to the About API.
- Migrate PostHog instance telemetry to the runtime/platform shape and merge the
duplicate runtimeInfo in licensing-admin into the shared one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Maintain a CHANGELOG.md (Keep a Changelog format) at the repo root and surface
it on the admin About page:
- The page now shows the running build's short commit hash next to the version,
linked to the GitHub commit. Commit is injected at build time via a new
resolveAppCommit() (ZPAN_APP_COMMIT -> WORKERS_CI_COMMIT_SHA -> git rev-parse),
wired through vite/tsup defines, the node entry, Docker, and CI.
- A new admin-only GET /api/system/changelog endpoint fetches CHANGELOG.md from
master on GitHub, caches it, parses the latest released version, and reports
whether an update is available (semver compare against the running version).
- The About page renders a "latest version" row with an update-available badge
and a side drawer that displays the changelog markdown.
Tests cover the semver compare, changelog parse/fetch caching, the API wrapper,
and the route (admin-gated, parsed payload).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(licensing): regroup comparison table by capability and add social-login/downloader gates
Decouple the feature comparison table's grouping axis from the pricing tier:
features are now grouped by capability (core/advanced) while edition
availability is expressed purely by the per-edition cells. Coming-soon
features show a badge next to the name with a muted check in the target
edition column, so it's clear which edition they will land in.
Tier reclassification (per product decision):
- Social login & OIDC: free = 1 provider, Pro/Business = unlimited
- Downloaders: free = 1, Pro/Business = unlimited
- Site announcements, Multi-IdP SSO, LDAP/SCIM, Analytics: Business-only
New runtime gates (enforced, mirroring the storages count-gate):
- social_login_unlimited in POST /api/admin/auth-providers (402 on 2nd)
- downloaders_unlimited in POST /api/admin/downloaders (402 on 2nd)
- site_announcements added to BUSINESS_ONLY_FEATURES
Copy cleanup:
- Rename rows that embedded a limit word: "Unlimited Team Workspaces"
-> "Team Workspaces", "Storage Backends" -> "Storages"
- Clarify "Storage Plans" -> "Sell Storage & Traffic" (the quota_store feature)
- Make the Licensing page intro edition-neutral (Pro + Business) instead of
the leftover Pro-only copy
Tests: add gate tests for both new limits; seed licenses in the existing
multi-provider/multi-downloader tests; switch announcement tests to a
Business license.
Note: site_announcements moving to Business takes full effect for real
licenses only once zpan-cloud stops listing it in Pro certificates; the
local edition-derived path is already updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(licensing): drop unused per-cert feature override, derive from edition
The optional `features[]` override on the license certificate was added with
the Pro/Business split but was never exercised: real certs carry only
`edition`, and entitlements are derived from it via the feature registry
(PRO_GATE_KEYS minus BUSINESS_ONLY_FEATURES). The override was dead in the
normal flow and duplicated the edition→feature mapping in two places.
Remove it so edition is the single source of truth:
- Drop `LicenseAssertion.features` and `normalizeFeatures` (verify.ts)
- `effectiveFeatures(edition)` no longer takes/honors an override list
- Simplify the test seed helpers (no `features` arg, no test-side
business-only set) and update licensing tests to assert edition-derived
entitlements
`BindingState.features` (the resolved list exposed to the client) is kept.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(downloader): regenerate OpenAPI spec and Go client for new 402 response
The downloaders create route gained a 402 (feature_not_available) response
for the free-plan limit; regenerate the committed OpenAPI document and Go
client so openapi:downloader:check passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an admin About page (/admin/about) showing version, instance metadata
(id/name/url/runtime/server OS/Node version) and edition status, plus two
CTAs: GitHub (star) and ZPan Cloud. When the instance holds a valid license
the cloud CTA deep-links to the certificate detail page.
- server: expose GET /api/system/instance (admin-only); extract shared
runtimeInfo() helper; move InstanceInfo to shared types
- ribbon: link to the About page, relabel Community -> Free, recolor
Pro (gold) / Business (indigo) to match the cloud certificate palette
- licensing: BoundStatusCard "Manage" deep-links to the certificate detail
page when active with a license_id
- useEntitlement: expose licenseId
- tests: api wrapper + system endpoint + ribbon coverage
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Support independent Pro and Business licensing, migrate Cloud store integration through the SDK, gate Business-only credit billing features, and validate the Cloud store E2E flow.
Replace ZPAN Store Wallet semantics with Credits-only APIs and UI.
Proxy Cloud credits through credit-account routes, send unitless gift-card credits, and block metered downloads on insufficient Cloud credits before presign while rolling back local counters.
Verification waiver: E2E fails only because the hosted Cloud target rejects the merged Credits gift-card payload; AK follow-up kxel47x6x6l3 tracks that target gap.