Commit Graph
971 Commits
Author SHA1 Message Date
Jasper VanandClaude Fable 5 7bad8d2aea fix: audit must-fixes + product gaps (SSRF, move-quota, multipart, password-reset, trash retention, notifications) (#428)
* 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>
2026-06-12 20:06:25 -04:00
saltbo d693837150 chore: update default branch references from master to main 2026-06-12 12:45:39 -04:00
Jasper VanandClaude Fable 5 c55e806b44 refactor(admin): dedupe format helpers, merge entitlement dialog, group team routes (#427)
Post-merge quality cleanup from the #426 review:

- Extract getInitials / formatDate / formatStorageUsage into src/lib/format.ts
  and migrate the admin users + teams pages off their local copies (also fixes
  a latent crash: users pages used part[0].toUpperCase() on empty segments).
  Unlimited quota now renders as ∞ consistently.
- Merge GrantUserEntitlementDialog + GrantOrgEntitlementDialog into one
  GrantEntitlementDialog keyed by a { kind: 'user' | 'team' } target; the
  dialog's labels move to a shared admin.entitlement.* namespace and the
  per-namespace dialog-only keys are removed.
- Move the org entitlement CRUD endpoints from /api/admin/quotas/:orgId to
  /api/admin/teams/:orgId so all team-admin functionality lives under one
  route tree; /api/admin/quotas keeps only the overview list. Frontend wrappers
  and tests follow.

No behavior change. lint/typecheck/test/test:cf all green.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:03:38 -04:00
Jasper VanandClaude Fable 5 583678967d feat: spaces, quota ownership, and sharing — design doc §5 implementation (#426)
* 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>
2026-06-12 01:12:45 -04:00
saltbo de3b68ecba chore(release): v2.7.4 v2.7.4 2026-06-11 12:36:38 -04:00
Jasper VanandClaude Opus 4.8 401eb3f7f9 fix(admin): register default quota input with valueAsNumber (#425)
The default org quota field was registered without `valueAsNumber`, so a
typed value was stored as a string. The save handler then ran
`Number.isFinite(values.quotaValue)` on the raw form value (bypassing the
zod coerce), which returns false for strings — making every manually
entered positive number fail with "Quota must be a positive number".
Leaving the default untouched worked only because reset() seeds it as a
number.

Register with `valueAsNumber: true` so the number input stores a number.
Add a regression test that types a different value (exercising onChange,
which the existing test missed by reusing the default display value).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:36:26 -04:00
saltboandClaude Opus 4.8 ce314538b7 test(licensing): assert PATCH confirm in pairing integration test
The pairing poll integration test still expected the old POST
/licenses/:id/confirm callback URL. Update it for the PATCH /licenses/:id
{ status: 'confirmed' } contract (method + body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:13:28 -04:00
saltboandClaude Opus 4.8 b84766d80e test(store): update checkout redirect test for promotionCode arg
createCloudCheckout now takes a third promotionCode argument; the redirect
page forwards it. Fix the call assertion and cover the coupon path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:04:11 -04:00
saltboandClaude Opus 4.8 5ce3845757 feat(store): add coupon entry to checkout
Stripe's native promo-code field is disabled on Cloud checkout, so collect
the coupon on our side. Clicking a plan/credit purchase now opens a confirm
dialog with a coupon input; applying a code calls the Cloud discount-quote
endpoint and shows the server-computed subtotal/discount/total. Confirming
threads the code through to the Stripe payment session.

Uses zpan-cloud-sdk 2.2.0 (discount-quotes resource). Adds api wrappers with
tests and the confirm dialog; updates the storage page checkout flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:33:57 -04:00
saltboandClaude Opus 4.8 4f58c08b87 refactor(licensing): confirm cloud license via PATCH
Cloud replaced POST /licenses/:id/confirm with PATCH /licenses/:id
{ status: 'confirmed' }; follow the new SDK 2.2.0 contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:33:27 -04:00
saltboandClaude Fable 5 60c51cf3b1 fix(auth): eliminate get-session hangs from cross-request pending init
better-auth starts its $context init synchronously inside betterAuth(),
within whichever request constructs the instance. Init eagerly resolves
all social providers, and ours were 35 async functions doing one D1
query each. When the isolate's first request didn't touch auth (share
SSR, /r/*, public APIs) or disconnected mid-init, its I/O context died
with the queries in flight and $context never settled — on Workers a
pending promise awaited from a later request never resolves, so the
cached auth instance hung every subsequent auth call in the isolate
(the recurring "get-session pending forever / 10s timeout" reports).

- load all OAuth provider configs with one snapshot query; register
  builtin providers as static objects (init does zero per-provider I/O)
- await auth.$context before returning from createAuth so a cached
  instance can never carry a pending promise tied to its creating
  request
- only load captcha config for captcha-protected endpoints instead of
  every /api/auth/* request
- cache the resolved site public origin at module scope (the WeakMap
  was keyed by the per-request db instance and never hit on Workers);
  cache settled values only, never promises
- client: share one in-flight get-session across callers regardless of
  TTL, cache resolved values for 5s, never cache failures

Regression tests pin the invariants: createAuth performs exactly one
DB query during init and returns with $context already settled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:31:29 -04:00
saltbo a3866e2f67 perf: fix get-session worker slowness and implement client-side cache 2026-06-10 22:45:59 -04:00
saltbo 47b11bfcca fix: update the get-session cache logic
Signed-off-by: saltbo <saltbo@foxmail.com>
2026-06-10 21:51:37 -04:00
saltboandClaude Opus 4.8 fa136a5112 fix(upload): make Content-Disposition Latin-1 safe for non-ASCII filenames
Uploading a file with a non-ASCII name (Chinese, emoji, …) failed with
"Failed to execute 'setRequestHeader' on 'XMLHttpRequest': String contains
non ISO-8859-1 code point." The presigned PUT's signed Content-Disposition put
the raw filename in the plain `filename="..."` parameter, which the browser
then rejects when setting it as an XHR request header.

Add a shared attachmentContentDisposition() helper that keeps `filename=`
ASCII-only and carries the real name in `filename*=UTF-8''`, and route all
three construction sites through it so the signed and client-returned values
stay identical (the value is part of the SigV4 signature).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:01:13 -04:00
saltbo 77ca806b5c fix(store): auto-cancel pending plan order on duplicate checkout error 2026-06-10 15:46:01 -04:00
Jasper VanandClaude Opus 4.8 e6e67c5fe4 feat(licensing): harden cloud pairing — env keys, clear errors, confirm handshake (#424)
Three related robustness fixes for the cloud pairing flow:

1. Trusted license public keys are env-configurable (ZPAN_LICENSE_PUBLIC_KEYS)
   instead of hardcoding dev keys in source — a leaked dev key is rotated via
   config and never baked into production builds. Registered in all platform
   factories.
2. Certificate verification surfaces a specific rejection reason
   (signature/issuer/instance/expired/host), and the pairing modal distinguishes
   a cert-verification failure from a genuine timeout instead of showing both as
   "expired". On failure the poll handler rolls back the orphaned cloud binding.
3. After verifying + storing the certificate, the instance confirms the binding
   to the cloud (zpan-cloud-sdk 2.1.0's POST /licenses/:id/confirm) so the cloud
   pairing page resolves to success only once the instance actually accepted it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:43:46 -04:00
saltboandClaude Fable 5 e56d3dc61a feat(server): auto-trust loopback and LAN origins without TRUSTED_ORIGINS
Sign-in via 127.0.0.1 or a LAN IP failed with "Invalid origin" unless the
user manually configured TRUSTED_ORIGINS. better-auth's trustedOrigins now
uses the function form: origins on localhost, 127.0.0.0/8, ::1, or RFC 1918
private ranges are trusted per request. Browsers set the Origin header
themselves, so a private address in it proves the page was served from the
user's own machine or LAN — safe to trust for CSRF purposes.

Also set advanced.disableOriginCheck: false explicitly: better-auth
silently disables the origin check under NODE_ENV=test, so no test ever
exercised real CSRF behavior. Test helpers now send an Origin header on
cookie-bearing requests, like real browsers do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 01:18:35 -04:00
saltboandClaude Opus 4.8 d2f3a34f05 fix(server): log underlying cause chain for failed D1 queries
Drizzle wraps the real D1 error in DrizzleQueryError.cause; both the access
log and the origin-detect catch only logged .message, surfacing just
"Failed query: <sql>" with no reason. Add formatError() to flatten the cause
chain and use it at both sites so D1 failures are diagnosable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:42:50 -04:00
saltboandClaude Opus 4.8 e4b497ac92 docs: add v2 launch offers page and README link
Introduce the three ways to earn ZPan Pro for free, celebrating the v2
launch: old-stargazer reward (claim by 2026-09-01), contributor gift, and
stargazer loyalty. Link it from a header callout and the Documentation
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:58:53 -04:00
saltboandClaude Opus 4.8 bb801d42a4 docs(changelog): drop top-level title, start directly at version entries
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:56:35 -04:00
saltboandClaude Opus 4.8 7985e022a8 docs(changelog): drop language-switcher nav, keep each changelog standalone
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:36:38 -04:00
saltboandClaude Opus 4.8 94a6bf5a18 docs(changelog): add product-oriented v2.7.3 entry across all languages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:24:14 -04:00
saltbo f1348054e2 chore(release): v2.7.3 v2.7.3 2026-06-09 15:28:41 -04:00
saltboandClaude Opus 4.8 66ae9a38ea docs(i18n): translate README and CHANGELOG into 6 languages
Add zh-CN, ja, ko, ru, es, pt-BR translations under docs/i18n/, keeping
the canonical English README/CHANGELOG at the repo root. Each file gets a
language switcher bar with the current language bolded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:25:17 -04:00
saltbo 15698aabab ci(release): guide developer to pnpm release on version mismatch 2026-06-09 15:21:59 -04:00
saltboandClaude Opus 4.8 53ca110218 feat(about): split runtime into runtime engine + deployment platform
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>
2026-06-09 14:56:29 -04:00
saltboandClaude Opus 4.8 b880ebee75 feat(about): widen changelog drawer, refine styling, unlink commit
- Wider drawer (max-w-xl) with consistent px-6 padding.
- Larger version headings and cleaner changelog markdown styling.
- Show the commit hash as plain text (no GitHub link).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:23:15 -04:00
saltboandClaude Opus 4.8 dc936e5eff feat(about): add changelog refresh button that bypasses the cache
The changelog is cached server-side (1h) and by React Query, so stale content
lingered. Add a refresh icon in the drawer header that calls the endpoint with
?refresh=true to skip the server cache and replaces the cached query data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:20:02 -04:00
saltboandClaude Opus 4.8 b9d4429286 feat(about): dedicated changelog renderer with per-version separators
The drawer reused the announcement markdown styling, which made versions (h2)
and their Features/Fixes groups (h3) look nearly identical with no separation.
Add a changelog-specific renderer: each version is a bordered section with a
larger bold heading, and Features/Fixes render as small muted labels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:16:20 -04:00
saltboandClaude Opus 4.8 3349cfeba3 docs(changelog): drop maintainer preamble from rendered file
The intro and maintainer note showed up verbatim in the About page drawer.
The drawer already has its own title/subtitle, so start the file at the first
version. Maintainer guidance lives in shared/constants.ts comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:13:43 -04:00
saltboandClaude Opus 4.8 4a873de209 docs(changelog): split each version into Features and Fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:06:19 -04:00
saltboandClaude Opus 4.8 4333655c24 docs(changelog): drop Unreleased section
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:04:33 -04:00
saltboandClaude Opus 4.8 547ff26b5d docs(changelog): rewrite as product-facing log for all v2 releases
Regenerate CHANGELOG.md from the GitHub release notes, covering every release
from 2.0.0 to 2.7.2. Entries describe user-facing value (not commit-level
detail) and each links to its GitHub release for the full technical log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:02:04 -04:00
saltboandClaude Opus 4.8 6aa5257f01 feat(about): take latest version from GitHub Releases, keep CHANGELOG drawer
The latest-version indicator now reads the newest published GitHub Release's
tag_name (the source of truth for "what shipped") instead of parsing the
changelog file. The drawer still renders the hand-maintained, product-facing
CHANGELOG.md — the auto-generated release notes are too technical for end users.

- Release lookup is best-effort: a rate-limited/unreachable GitHub API hides the
  version badge but never breaks the drawer (CHANGELOG.md drives the markdown).
- Drop parseLatestVersion; fetch the two sources concurrently behind the cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:57:05 -04:00
saltboandClaude Opus 4.8 17741ef594 refactor(about): consolidate version row, move commit to its own line
- Show the latest-version status as a clickable badge inside the Version row
  instead of a separate row; remove the duplicate header update badge.
- Move the commit hash to its own "Commit" row below the version.
- Drop the standalone Changelog button; the drawer now opens by clicking the
  version number or the latest-version badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:47:45 -04:00
saltboandClaude Opus 4.8 e700fd4977 feat(about): add changelog drawer, latest-version check, and commit hash
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>
2026-06-09 13:38:29 -04:00
Jasper VanandClaude Opus 4.8 5a5583b4ed feat(licensing): regroup comparison table by capability and add social-login/downloader gates (#423)
* 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>
2026-06-09 13:32:59 -04:00
saltboandClaude Opus 4.8 42342be665 fix(version): resolve app version from package.json, not git describe
Cloudflare Workers Builds deploys master continuously without setting
ZPAN_APP_VERSION, and its checkout has no tags, so git describe --always fell
back to a bare commit hash — the deployed Worker showed a commit instead of a
version. package.json is present in every build environment, so read the
version from it when ZPAN_APP_VERSION is unset. Release/Docker/GH-action
deploys still pass the exact tag via the env var, which keeps priority.

Release process: bump package.json on each release so Workers Builds reports
the right version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:36:15 -04:00
saltboandClaude Opus 4.8 120faaf61a ci: add Cloudflare deploy dry-run smoke job
wrangler deploy --dry-run compiles the Worker and validates wrangler.toml
plus the @cloudflare/vite-plugin deploy config (assets directory, queues,
bindings) without contacting Cloudflare, so it needs no secrets and runs on
fork PRs. Guards the deploy regressions unit tests miss — a dropped build
step (as in #417) or a binding added to wrangler.toml without provisioning,
the two failures behind #415.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:19:42 -04:00
bae4d62d8a fix(deploy): provision archive queue and restore vite build step (#415)
v2.7 added the zpan-archive-jobs queue binding but the CF deploy workflow
never created it, and bd9f047 (#417) accidentally dropped the vite build
step. Add a queue-provisioning step (from @wshijiang) and restore an explicit
Build step so @cloudflare/vite-plugin generates the deploy config with the
correct assets directory.

Co-Authored-By: saltbo <rdsaltbo@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:12:25 -04:00
saltboandClaude Opus 4.8 ee8eea1a15 fix(i18n): add missing sitePublicOrigin admin settings keys
The Public URL field referenced admin.settings.sitePublicOrigin plus its
Placeholder and Hint variants, but none existed in en.json or zh.json, so
the label, placeholder, and helper text rendered as raw keys. Add all three
to both locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:56:48 -04:00
saltboandClaude Opus 4.8 0063557d09 fix(docker): repair image startup and guard it in CI
The bundled app-version.mjs self-exec block (`import.meta.url ===
file://${process.argv[1]}`) fired at runtime once tsup inlined it into
entry-node.js, since the container runs `node dist-server/entry-node.js`.
It shelled out to `git describe`, which is absent in the image, crashing
the server on boot and leaving the compose stack unhealthy. Drop the
unused CLI entry; resolveAppVersion is still build-time inlined.

Also stop the CLI build from overwriting the `latest` tag: metadata-action
defaults to latest=auto, so cli-meta re-added a bare `latest` that, running
after the server build, clobbered it with the downloader image. Pin
latest=false on cli-meta.

Add a docker-smoke CI job that runs `docker compose up --wait` so an image
that fails to boot turns CI red, and give the GeoIP download --retry to cut
flake on that external fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:51:13 -04:00
Jasper VanandClaude Opus 4.8 891e331ca8 feat(admin): add About page with instance info and edition-aware cloud links (#422)
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>
2026-06-09 10:40:54 -04:00
saltboandClaude Opus 4.8 b10a1f3d84 fix(quota): chunk batch quota IN queries under D1's 100-param cap
getEffectiveQuotasByOrg passed every org id into a single IN clause. With
more than ~100 orgs the bound-parameter count exceeded Cloudflare D1's
per-query limit (100), so GET /api/admin/quotas threw "Failed query" in
production. Local SQLite has no such cap, so tests and CI never hit it.

Split the org ids into chunks of 90 (leaving headroom for the extra
status/timestamp params on the entitlements query) and run the chunked
queries concurrently, aggregating in memory. Query count stays constant
(2 per chunk) instead of the old 8 per org.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:18:19 -04:00
Jasper Van 232ad896c3 feat(admin): add diagonal license-edition corner ribbon to admin layout (#421)
* feat(admin): add diagonal license-edition corner ribbon to admin layout

Add a fixed diagonal corner ribbon (top-right) at the admin Layout level that
shows the current license edition with appropriate color and link:
- Community (unbound): gray #64748B, links to GitHub
- Pro (bound, edition=pro): brand blue #1A73E8, links to GitHub
- Business (bound, edition=business): gold #F59E0B, links to cloud dashboard

Changes:
- src/components/billing/LicenseRibbon.tsx: new ribbon component
- src/components/billing/LicenseRibbon.test.tsx: 15 co-located tests
- src/hooks/useEntitlement.ts: expose cloudDashboardUrl from BindingState
- src/routes/_authenticated/admin/route.tsx: render LicenseRibbon at layout level
- src/i18n/locales/en.json + zh.json: add admin.licenseRibbon.* keys
- src/routes/_authenticated/teams/index.test.tsx: add cloudDashboardUrl to mock

Agent-Profile: https://agent-kanban.dev/agents/e41f6168983d6e3f

* fix(admin): address review findings on LicenseRibbon

- Type editionKey parameter as LicenseEdition | null (import from @shared/types)
  to stay consistent with the shared type contract
- Increase container from h-20 w-20 to h-24 w-24 for a larger visible and
  clickable ribbon area in the corner
- Strengthen aria-label test to assert the exact interpolated key value
  rather than just truthiness

Agent-Profile: https://agent-kanban.dev/agents/e41f6168983d6e3f
2026-06-09 06:22:55 -04:00
saltboandClaude Opus 4.8 9c16adbdc0 perf(quota): batch admin quota listing and move monthly reset to cron
The admin quota listing called getEffectiveQuota per org, firing ~8
sequential queries each (N+1). On D1 every query is a network round-trip,
so the endpoint scaled linearly with org count and risked the Workers
subrequest cap.

- Add getEffectiveQuotasByOrg: resolves every org in 2 queries (quota
  rows + active entitlements) and aggregates in memory. Route uses it.
- Remove the inline traffic-period reset writes from the read paths
  (getEffectiveQuota and the listing route). getEffectiveQuota already
  normalizes a stale period in memory, so reads stay correct.
- Add resetExpiredTrafficQuotas and run it on a new monthly cron
  (0 0 1 * *) for CF Workers and a daily idempotent interval for Node.
  The consume write path keeps its atomic reset-and-consume as a
  self-healing fallback if a scheduled run is missed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:08:27 -04:00
Jasper VanandClaude Opus 4.8 f922fd4d6f feat(quota): allow admins to edit and revoke granted entitlements (#419)
Admin-granted storage entitlements were insert-only. Add edit + revoke
for admin_grant entitlements (PATCH/DELETE /api/admin/users/:id/entitlements/:eid),
guarded so system-managed sources (free_plan, cloud_order) are untouchable.
Includes RPC wrappers, edit/revoke UI on the user detail page, i18n, and
full unit + integration coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:40:13 -04:00
Jasper VanandClaude Opus 4.8 af43701daf fix(version): set app version global in node entry (#418)
* fix(version): resolve app version at runtime in node entry

E2E runs the Node server via tsx, which bypasses the tsup build-time
define, leaving __ZPAN_APP_VERSION__ unset so getAppVersion throws and
/api/licensing/pair returns 500. Resolve the version at runtime via
resolveAppVersion (git describe) when the global is unset; in the built
output the define inlines the constant, so the branch is never reached
and git is not invoked in production. Keeps the git-describe version as
the single source of truth across all paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(version): inject app version into docker build

The Docker build excludes .git from its context (.dockerignore), so the
build-time git describe in build:node would throw and break the image
build. Let resolveAppVersion read ZPAN_APP_VERSION, set it from an
APP_VERSION build arg in the builder stage, and pass the release tag from
the release workflow. git describe stays the default everywhere else, so
the version is unified across CF Workers, tsx, and Docker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:29:47 -04:00
saltbo 43e92caaa6 chore(config): consolidate project tooling 2026-06-08 15:17:54 -04:00
saltbo ef122ec317 fix(version): inject app version at build time 2026-06-08 14:58:28 -04:00