Resolve Dependabot alert #36 (esbuild <= 0.24.2, medium): "esbuild enables
any website to send any requests to the development server and read the
response."
The vulnerable esbuild 0.18.20 was dragged in transitively by drizzle-kit's
deprecated @esbuild-kit/esm-loader → @esbuild-kit/core-utils (both "Merged
into tsx"). drizzle-kit 0.31.10 — the latest — still declares the legacy
loader even though it now uses tsx, so bumping drizzle-kit can't fix it.
Scoped pnpm override forces only @esbuild-kit/core-utils>esbuild to 0.25.12
(already resolved in the tree via drizzle-kit's own esbuild ^0.25.4), so it
dedups to a single version and leaves vite/tsup/vitest esbuild untouched.
Verified: drizzle-kit `db:generate` loads the TS config + full schema and
exits 0 with no spurious migrations; typecheck and production build green.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve Dependabot security alerts:
- nodemailer (high): the message-level `raw` option bypassed
disableFileAccess/disableUrlAccess (arbitrary file read + SSRF). Our
email gateway only sends `html` over an SMTP transport (no `raw`, no
remote attachment fetching, no OAuth2/proxy), so neither the vuln nor
the 9.0 TLS-cert-validation breaking change affects our usage.
- undici (3 high / 2 med / 2 low): the override pinned undici at 7.24.8
(a leftover dedup pin from the pnpm migration, not a real constraint).
Bump it to 7.28.0; consumers (jsdom/vitest/better-auth/miniflare/
wrangler) all accept ^7, so it dedups to a single patched version.
Verified: typecheck, 4347 unit/integration + 59 CF Workers tests, and a
production vite build all green.
Supersedes #458.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.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>
No more docs/openapi/downloader.json and no curated subset. The Go client is
generated straight from the complete, live /api/openapi.json: a single
scripts/openapi-client.ts boots the in-memory app, reads the whole merged
document, and feeds it to oapi-codegen via a throwaway temp file — only the
generated client.gen.go is committed.
- delete docs/openapi/downloader.json, cmd/oapi-codegen.yaml, and the three
build/generate/check scripts; replace with one scripts/openapi-client.ts
(`pnpm openapi:client` / `--check`).
- generate from the full document — every endpoint, no allowlist/prune. The
only transforms are whole-document mechanics for oapi-codegen (3.1→3.0
nullable, strip security, declare better-auth's missing path params).
- rename the CI step + package scripts openapi:downloader:* → openapi:client*.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(openapi): global OpenAPI document + Scalar UI, drop hand-written stubs
Replace the curated, partly hand-written "downloader" OpenAPI doc with a
single global document generated from the real routes.
- main app → OpenAPIHono; serve the aggregated spec at /api/openapi.json and
the Scalar reference UI at /api/docs. A resource appears in the doc as soon
as it is converted to `.openapi()` — no curation, no drift.
- enable better-auth's openAPI plugin; the auth/device flow now documents
itself at /api/auth/reference instead of hand-written route stubs.
- convert objects.ts and events.ts to self-documenting OpenAPIHono routes;
RPC types preserved (responses go through unwrap<T>, {id}→:id accessors hold).
- tag operations (Objects/Events/Download Tasks/Downloaders) + top-level tags
so Scalar groups them.
- delete server/openapi/downloader.ts and its device/object/events stubs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(openapi): merge better-auth schema into one doc; regen Go client from it
Make /api/openapi.json a single fully-generated document and drive the Go
downloader client from it — no hand-written/maintained spec.
- merge better-auth's auto-generated schema (auth.api.generateOpenAPISchema)
into /api/openapi.json, prefixed under /api/auth. The device-authorization
flow and the rest of the auth API now appear in one doc + Scalar.
- correct one upstream bug in the merge: better-auth advertises
POST /device/token as { session, user } but its handler returns the OAuth
token { access_token, token_type, expires_in } — override that one response
so the doc and the generated client match reality.
- rewire the Go-client codegen to generate from the merged document: a new
build-client-spec.ts boots the in-memory app, reads the real merged
/api/openapi.json, scopes it to the downloader's paths (device + downloads +
objects), prunes unreferenced components, strips security metadata, and
downconverts 3.1 nullable unions to 3.0 for oapi-codegen.
- regenerate docs/openapi/downloader.json + cmd/internal/openapi/client.gen.go
and adapt cmd/internal/client to the regenerated device types (inline request
bodies, optional pointer/number fields) and the 201-only object create.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): enforce http→usecase boundary + extract storages usecase
Adds an AST-based lint (scripts/lint-http-boundary.ts, `pnpm lint:http`, wired
into CI) that forbids http handlers from reaching into deps ports directly
(`c.get('deps').<port>.<method>()`) — the runtime signal of business logic
leaking into the delivery layer, which dependency-cruiser's import-graph rules
cannot see. It ships with a migration ratchet of the 30 handlers that still
violate: CI fails on any new violation and on any ratcheted file that has become
clean, so the list only shrinks. When empty, the boundary is locked.
Converts storages as the first usecase-per-resource example:
- usecases/storage.ts owns all storage business rules (Community storage limit,
egress-credit-billing feature gate, activity logging)
- http/storages.ts is now thin: validate → call usecase → serialize
- usecases/storage.test.ts exhausts the branches with fake ports (14 cases)
- storages removed from the ratchet (29 remain)
Behavior preserved: storages.integration.test.ts (24) unchanged and green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract profile + notification resource usecases
Converts two owner/public single-port resources to the usecase-per-resource
convention (handlers now only validate → call usecase → serialize):
- usecases/profile.ts (getPublicProfile) + usecases/notification.ts
(list/unreadCount/markRead/markAllRead), each with fake-port unit tests
- http/profile.ts, http/notifications.ts no longer touch deps ports
- ratchet: 29 → 27
Behavior preserved: profile + notifications integration suites (23) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract audit + quota + announcement resource usecases
- usecases/audit.ts (listAuditEvents)
- usecases/quota.ts (listQuotaOverview with org-type parsing, getUserQuota with
personal-org fallback)
- usecases/announcement.ts (user/admin list + CRUD)
Handlers keep only pure input parsing (pagination clamp) + serialization; no
deps-port access. Each usecase has fake-port unit tests (12 cases).
ratchet: 27 → 24
Behavior preserved: audit/quotas/announcements integration suites (65) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract auth-provider/background-job/email-config/invite-code/site-invitation/user usecases
Wave of six independent resources converted to usecase-per-resource (parallel
subagents, centrally verified). Each: new usecases/<resource>.ts holding all
port access + business rules, a thinned handler (validate → call usecase →
serialize, no deps-port access), and fake-port unit tests.
- auth-provider.ts: provider config list/upsert/delete; OIDC validation +
social-login free-limit gate as outcome unions
- background-job.ts: list/get/cancel/create/retry; keeps port-thrown
BackgroundJobError mapping
- email-config.ts: masked get / save rows / send-test (send_failed outcome)
- invite-code.ts: list/validate/generate(expiry policy)/delete outcome union
- site-invitation.ts: create/resend/revoke/getByToken; email-before-write
ordering preserved
- user.ts: admin user status/delete + entitlement CRUD; repo-chosen failure
statuses threaded through unchanged
ratchet: 24 → 18
Verified: typecheck, lint:http, lint:arch, biome all clean; 90 new unit tests +
124 existing integration tests green (behavior preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract ihost/team/branding/me/trash/system resource usecases
Second parallel wave (centrally verified). Handlers thinned to validate → call
usecase → serialize; all port access + business rules moved into usecases.
- image-hosting.ts (extended) + image-hosting-config.ts: ihost upload/list/delete
+ config CRUD with CF custom-hostname lifecycle; quota→422 preserved
- team.ts: /api/teams + /api/admin/teams (invite links, join, activity feed,
org logo, admin quota entitlements); role checks + repo-failure threading
- branding.ts (extended): admin write orchestration (logo/favicon upload,
theme, single audit event) + reset; white_label gating stays in middleware
- me.ts: avatar upload/delete (gateway status passthrough, DB-first delete)
- trash.ts: empty-trash (reuses purge.ts; trash_empty audit only when >0)
- system.ts: instance info, changelog, system-options CRUD (signup/captcha/quota
validation ordering preserved)
Also removed dead code: the speculative team.ts createTeamGate (the team-create
limit is enforced in auth.ts via licensing.checkTeamLimit; nothing called it).
ratchet: 18 → 10
Verified: typecheck, lint:http, lint:arch, biome clean; 148 new unit tests +
228 integration tests green (behavior preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract cloud-store + licensing-admin + events usecases
Third parallel wave (centrally verified + test fixups).
- cloud-store.ts: storefront reads, checkout/orders, and webhook delivery
(cloud event token verification + idempotency); binding gate as outcome union
- licensing.ts (extended, admin section): initiatePairing / pollPairing (cert
verify + rollback) / triggerRefresh / unbindLicense
- events.ts: the multiplexed SSE stream as a (deps, params, signal, emit)
usecase; the handler owns the ReadableStream/Response and feeds ONE
AbortController from both teardown paths (request abort + body cancel)
Streaming fix: guard the stream controller so a consumer cancel() — which
already closes it before firing the abort listener — no longer double-closes
(ERR_INVALID_STATE), eliminating the unhandled errors in the events suite.
Test fixups (behavior was correct, verified by integration): cloud-store fake
rebuilt the bound client per request and reset its response queue (singleton
now); licensing-admin unit test forced onto the node env (paseto-ts needs a
real TextEncoder).
ratchet: 10 → 5
Verified: typecheck, lint:http, lint:arch, biome clean; 61 unit + 77 integration
tests green (behavior preserved).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract download-traffic metering into a usecase
traffic-metering-utils.ts was an http helper that read deps off the request and
ran the quota+egress download meter inline. Moves the decision into
cloud-traffic-metering.ts as meterDownloadTraffic / reportDownloadEgress
(deps-first, returning a plain {ok|quota_exceeded|insufficient_credits}
outcome). The http helper stays as a thin Context adapter that resolves the
cloud base URL, calls the usecase (deps passed whole), and renders the 422/402
responses — so its four consumers (shares, objects, redirect, webdav) are
unchanged and the file is now boundary-clean.
ratchet: 5 → 4
Verified: typecheck, lint:http, lint:arch, biome clean; cloud-traffic-metering
unit (13, incl. 3 new download tests) + 104 consumer integration tests
(redirect/objects-quota/share-public/cloud-traffic-metering) green — download
metering behavior preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract redirect + share + object resource usecases
The download-flow consumers. Each download orchestration (resolve → access/
expiry/limit gates → atomic increment → meter → presign → audit) moved into its
resource usecase, which calls meterDownloadTraffic/reportDownloadEgress(deps, …)
directly; the handler computes cloudBaseUrl, manages cookies, and renders the
route-specific 302/JSON/410/422/402 responses from the returned outcome.
- usecases/redirect.ts: /r/:token (ds_ direct share + ih_ image hosting), with
refer-allowlist + presign-rollback
- usecases/share.ts: public + authed share routes; cookies become usecase
*decisions* the handler applies (view-dedup, password session); imports
save-to-drive + share-notification unchanged
- usecases/object.ts: upload sessions, confirm, list/move/trash/restore/delete,
copy/transfer, download; keeps ObjectUploadSessionError; consolidated two
identical write-access middlewares
- usecases/share-ref.ts: pure share-token helpers (HMAC ref codec, breadcrumb,
access gate, presign TTL) moved out of http/share-utils so usecases can import
them without reaching into http; share-utils re-exports them for handlers
ratchet: 4 → 1 (only webdav remains)
Verified: typecheck, lint:http, lint:arch, biome clean; 152 new unit + 206
integration tests (redirect/shares/share-public/objects/objects-quota) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract webdav resource usecase — http boundary fully locked
The last and largest handler (1273 lines, 63 violations). All WebDAV port
orchestration — auth resolution, path/lock/dead-property access, PROPPATCH, PUT
(streamed reservation + rollback), MKCOL, DELETE, MOVE, recursive COPY, and the
GET download metering — moves into usecases/webdav.ts. The handler keeps the
protocol machinery: XML multistatus rendering, status codes (207/201/204/423/
412/409/416), header parsing (Depth/Destination/Range/If/Lock-Token/Overwrite),
basic-auth/API-key parsing, and all streaming Response framing (FixedLengthStream,
single-range 206, multipart/byteranges). The GET path calls meterDownloadTraffic
directly; getWebDavObjectBody returns the S3 body for the handler to stream,
preserving the exact (storage, object[, range]) call shape and refund-on-failure.
ratchet: 1 → 0. `pnpm lint:http` now reports "http boundary fully locked".
Verified: typecheck, lint:http (LOCKED), lint:arch, biome clean; 43 unit + 41
integration tests (the 2027-line webdav spec) green — behavior preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(webdav): preserve api-key rate-limit message; deterministic object test dates
- resolveWebDavAuth now threads the original ApiKeyRateLimitError message
through its rate_limited outcome so the 429 body stays "Rate limit exceeded."
(the webdav auth refactor had hardcoded "Rate limited") — restores
api-keys-rate-limit.integration.test.ts.
- object.test.ts file() used argless new Date() in both the mock and the
expected value; a shared FIXED_DATE makes the deep-equal deterministic (it
flaked under full-suite load).
Full suite green: 4327 passed (184 files).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(e2e): isolate the e2e database — own throwaway DB, wiped each run
entry-node and e2e/global-setup both honor DATABASE_URL, but it defaulted to the
shared dev ./zpan.db and nothing wiped it — so a local `pnpm e2e` ran against
(and mutated) the dev database and wasn't clean between runs. playwright.config
now defaults DATABASE_URL to a throwaway .e2e/e2e.db (node runtime; CF uses D1)
and wipes it on every run, and sets reuseExistingServer:false so e2e never
silently reuses a running dev server. CI is unaffected (fresh box; reuse already
off). Opt out by setting DATABASE_URL yourself.
Verified: `pnpm e2e auth.spec.ts` (7 passed) ran on .e2e/e2e.db while ./zpan.db
stayed byte-for-byte unchanged (mtime+size identical).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): rename routes/ to http/ (clean-arch step 1)
The HTTP delivery layer was already split per-resource; align the directory
name with the hono-cf-clean-arch standard. Pure mechanical move via git mv;
updates the three server-side importers (app.ts, image-hosting-domain
middleware, openapi/downloader). No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): add clean-arch backbone + migrate activity to a repo
Introduce the composition root and dependency-injection seam:
- usecases/ports.ts (barrel) + usecases/ports/<resource>.ts: framework-free
port interfaces and DTOs
- usecases/deps.ts: the Deps aggregate consumed via c.get('deps')
- composition.ts: createDeps(platform) — the only place adapters are built
- app.ts sets deps in request context after platform middleware
First adapter: adapters/repos/activity.ts (ActivityRepo) replaces
services/activity.ts. All 14 call sites rewired (routes use
c.get('deps').activity.*; auth.ts and transitional services construct the repo
from db). DTOs are now plain shapes, not drizzle $inferSelect.
Behavior-preserving: typecheck + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract StorageRepo + migration tracker
services/storage.ts -> adapters/repos/storage.ts (StorageRepo). All 14 callers
rewired (http/middleware via c.get('deps').storages.*; transitional services via
createStorageRepo(db)). Port DTO reuses the shared Storage contract with Date
timestamps; the S3-credential 'Storage' type alias across 9 files now points at
StorageRecord. Data-layer test moved next to the repo.
Adds docs/clean-arch-migration.md as the living progress tracker.
typecheck + lint + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract Profile/Announcement/Notification repos
- profile -> ProfileRepo; the pure buildBreadcrumb moves to domain/breadcrumb.ts
- announcement -> AnnouncementRepo; notification -> NotificationRepo
- All callers rewired (routes via c.get('deps').*; auth.ts + services via
create<X>Repo(db)); data-layer tests moved next to their repos
- Test infra: createApp accepts an optional deps; createTestApp returns deps so
tests fake a port by spying on testApp.deps.* (events SSE failure test no
longer spies the service module)
typecheck + lint + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract OrgRepo (authz) + InviteRepo
- org -> OrgRepo (findPersonalOrg/getMemberRole/canReadOrg/canWriteToOrg/
isPersonalOrg); rewired across 4 routes + 2 auth middlewares + auth.ts
- invite -> InviteRepo; rewired invite-codes route + auth.ts
- data/unit tests for org & invite moved next to their repos
typecheck + lint + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract BackgroundJobRepo (+ BackgroundJobError to ports)
background-jobs -> adapters/repos/background-job.ts. The BackgroundJobError
(caught by http for status mapping) moves to usecases/ports per the standard.
Rewired: background-jobs route + events SSE (deps) + archive-processing
(transitional repo). Unit + data tests relocated.
typecheck + lint + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract QuotaRepo from effective-quota
The foundational quota leaf. effective-quota.ts -> adapters/repos/quota.ts
(QuotaRepo); the pure currentTrafficPeriod moves to domain/quota.ts; DTOs
(EffectiveQuota, CurrentStoragePlan) move to ports. Rewired 14 callers
(http -> deps.quota; services/auth/entry-node/workers.scheduled -> createQuotaRepo).
scheduled-worker test now mocks the adapter (createQuotaRepo) instead of the
service module.
typecheck + lint + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract TeamRepo + TeamInviteRepo
team -> adapters/repos/team.ts (TeamRepo; composes QuotaRepo for quota totals);
team-invite -> adapters/repos/team-invite.ts. teams-admin + teams routes use
c.get('deps').{teams,teamInvites}. Data tests relocated.
typecheck + lint + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* build(arch): enforce clean architecture via dependency-cruiser (ratchet) in CI
Adds .dependency-cruiser.cjs with the full hono-cf-clean-arch rule set and wires
pnpm lint:arch into CI. The drizzle-only-in-repos rule uses a shrinking
MIGRATION_PENDING allowlist so it passes today while still enforcing every
already-migrated layer; each future migration commit removes an entry. platform/
(Database driver type) and auth.ts are permanent named exceptions.
Currently green: 222 modules / 926 deps, 0 violations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): combine user + org-entitlements into UserAdminRepo
Resolves the pre-existing user <-> org-entitlements import cycle by merging both
into adapters/repos/user-admin.ts (UserAdminRepo); shared types (UserWithOrg,
QuotaEntitlementItem, UserOperationFailure, entitlement inputs) move to ports.
users + teams-admin routes use c.get('deps').userAdmin.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract SiteInvitationRepo
site-invitations -> adapters/repos/site-invitations.ts. Route uses
c.get('deps').siteInvitations; the email helper now receives siteName from the
handler (http stays out of adapters); auth.ts uses the repo. Result-type unions
moved to ports.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cf): fix storages.cf-test seed after StorageRepo extraction
cf-tests are excluded from typecheck; biome had pruned the transiently-unused
createStorageRepo import during the storage migration. Restore the import and
convert the platform.db seed calls. test:cf green (57 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(spec): introduce BDD-lite spec/ + spec<->test traceability lint
Adds the standard's product-spec layer:
- spec/*.feature (Gherkin, no Cucumber runner) — one per capability, scenarios
tagged @<capability>/<slug> + layer; spec/README.md documents the convention
- [spec: <id>] breadcrumbs on home tests
- scripts/lint-spec.mjs + pnpm lint:spec (wired into CI): every scenario id must
have a referencing test and every breadcrumb must match a scenario
Specced: storages, announcements, notifications, invite-codes, site-invitations
(41 scenarios, all traced). Specs grow per capability as the migration proceeds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract changelog + cf-custom-hostnames providers
Establishes adapters/providers/. changelog (GitHub releases/CHANGELOG) and
cf-custom-hostnames (CF for SaaS) move to adapters/providers/ behind
ChangelogProvider / CfHostnamesProvider ports (CfConflictError -> ports).
system + ihost-config routes use c.get('deps').{changelog,cfHostnames}.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): move db-transaction -> db/, path-template -> lib/
Two framework-free utilities leave services/ for their proper homes:
db/transaction.ts (the drizzle batch/transaction helper) and lib/path-template.ts
(object-key builder). Importers updated.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): migrate licensing subsystem drizzle to repos
license-state -> adapters/repos/license-binding.ts (LicenseBindingRepo);
instance-id + instance-info DB reads -> adapters/repos/instance.ts (InstanceRepo).
licensing/ (has-feature, refresh, entitlement, instance-info) now uses the repos
and imports no drizzle, so ^server/licensing leaves the dependency-cruiser ratchet.
licensing-admin route uses c.get('deps').{licenseBinding,instance}; service callers
construct the repos; instance-telemetry test mocks the adapter.
typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): move S3Service to adapters/gateways behind S3Gateway port
Establishes adapters/gateways/ + deps.s3. S3Service -> adapters/gateways/s3.ts
(implements S3Gateway; S3StorageCredentials -> ports). A thin services/s3.ts
re-export shim keeps the http routes (objects/webdav/ihost/share-utils) and the
21 prototype-spy tests working unchanged until those routes migrate to deps.s3;
s3-dependent services can now move to usecases using deps.s3.
typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): drain inline drizzle from me route (avatar -> ProfileRepo)
ProfileRepo gains setAvatar; the /api/me avatar handlers use c.get('deps').profiles
instead of inline user-table updates. 'me' leaves the dependency-cruiser ratchet.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): drain inline drizzle from quotas route (-> QuotaRepo.listOrgQuotaOverview)
The admin quota-overview join moves into QuotaRepo; the route uses
c.get('deps').quota. 'quotas' leaves the ratchet.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): SystemOptionsRepo drains auth-providers/system/email-config routes
New adapters/repos/system-options.ts (key-value access to systemOptions) + deps.systemOptions.
auth-providers, system, email-config routes drop inline drizzle and use
c.get('deps').systemOptions; all three leave the ratchet.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): drain inline drizzle from teams route (logo -> TeamRepo.setLogo)
TeamRepo gains setLogo; teams route uses c.get('deps').teams for logo set/clear
and drops its dead db locals. 'teams' leaves the ratchet.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): drain inline drizzle from ihost-config (-> ImageHostingConfigRepo)
New adapters/repos/image-hosting-config.ts + deps.imageHostingConfigs. The ihost-config
route's custom-domain CRUD uses c.get('deps').imageHostingConfigs (cf-hostnames already
via deps). 'ihost-config' leaves the ratchet.
typecheck + lint + lint:arch + 3807 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): loadBindingState -> usecase, hasFeature/effectiveFeatures -> domain
Finishes the feature-gate path: domain/licensing.ts (pure hasFeature/effectiveFeatures),
usecases/licensing.ts (loadBindingState(deps) using LicenseBindingRepo + cert verify).
licensing/has-feature.ts deleted. Rewired 10 callers (routes/middleware via
c.get('deps'); services via createLicenseBindingRepo(db)). Tests retargeted to the
new modules (domain + usecases licensing).
typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): extract StorageUsageRepo + storage-usage reservation usecase
The quota-reservation crown dependency. adapters/repos/storage-usage.ts
(StorageUsageRepo: rollbackReservations + reconcile); usecases/storage-usage.ts
(reserveStorageUsage/withStorageUsageReservation/StorageUsageMutationContext taking
{quota,storageUsage} deps); StorageQuotaExceededError -> ports. Rewired 9 callers
(objects/webdav/ihost routes via c.get('deps'); matter/image-hosting/archive/purge/
save-to-drive via constructed repos). Unblocks the matter/image-hosting clusters.
typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): migrate 5 leaf service clusters to clean-arch (parallel wave)
Extracted 7 services via parallel agents on file-disjoint components:
- instance-telemetry -> usecases/instance-telemetry (reuses instance + systemOptions ports)
- image-upload -> adapters/gateways/image-upload (ImageUpload port, deps.imageUpload)
- archive-jobs -> adapters/gateways/archive-jobs (ArchiveJobsGateway, deps.archiveJobs)
- zip-compress + zip-extract -> adapters/gateways/zip + adapters/repos/zip (ZipGateway + ZipPlanRepo)
- object-upload-sessions -> adapters/repos/object-upload-session (ObjectUploadSessionRepo)
- purge -> usecases/purge (pure usecase over existing s3/storages/storageUsage)
Routes (objects/teams/me/internal/background-jobs) now reach these via c.get('deps');
entry files + workers build deps via createDeps(platform). Barrels wired by hand.
typecheck + lint:arch (240 modules) + 3810 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(spec): add quotas/profile/licensing feature specs + traceability
29 new scenarios traced to existing integration tests via [spec: id] breadcrumbs.
lint:spec: 70 scenarios, all covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): migrate auth/webdav/cloud/branding/image-hosting clusters (parallel wave 2)
17 services extracted via 5 parallel agents on file-disjoint components:
- auth-account: email->EmailGateway, share-notification->ShareNotificationRepo,
member-count->MemberCountRepo, captcha->domain+usecase, signup-mode/team-count->usecases
- webdav-middleware: api-keys/download-tokens gateways, webdav-state/webdav-path repos,
webdav-xml->domain (pure)
- cloud: licensing-cloud->LicensingCloudGateway, cloud-store/cloud-traffic-report/
remote-download-usage repos (cloud-traffic-metering + licensing-refresh-runner folded in)
- branding: pure usecase over existing deps (no new port)
- image-hosting: ImageHostingRepo
12 new deps fields wired by hand. WebDavMatterRow DTO moved into the webdav-path port
(was importing services/matter, which cycled through the ports barrel); domain WebDavMatter
dirtype widened to number|null to match the nullable column. Ratchet shrunk: ihost.ts +
middleware/image-hosting-domain.ts no longer touch drizzle. services/ now 26->9 (matter crown).
typecheck + lint:arch (261 modules, no cycles) + 3810 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(spec): add users/audit/teams/avatar/background-jobs/events/health specs
64 new scenarios traced to existing integration tests. lint:spec: 133 scenarios, all covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): migrate share/save-to-drive/archive-processing/trash-retention (parallel wave 3)
- share -> ShareRepo (+ domain/share, transitional ShareMatterRow DTO); shares.ts now
holds ZERO drizzle (dropped from the ratchet)
- save-to-drive -> pure usecase over deps (s3/storages/storageUsage/quota/activity/share)
- archive-processing -> usecase + ArchiveTargetFolderRepo (archive-jobs gateway self-assembles
its deps subset from platform to avoid a composition cycle)
- trash-retention -> pure usecase
purge gains deps.share for share cascade-delete. 2 new deps fields wired. services/ now 9->5
(matter, matter-name-conflict, downloads, s3 shim, site-public-origin remain).
typecheck + lint:arch (265 modules, no cycles) + 3810 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(spec): add branding/email-config/auth-providers/system/image-hosting/webdav/quota-store specs
128 new scenarios traced to existing integration tests. lint:spec: 261 scenarios, all covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): migrate the matter keystone + site-public-origin (wave 4)
The crown. matter (644 lines, 17 exports) -> adapters/repos/matter.ts (MatterRepo: full
drizzle CRUD + conflict resolution) + usecases/matter.ts (confirmUpload quota-guarded) +
usecases/ports/matter.ts (Matter DTO + NameConflictError); matter-name-conflict -> domain.
Fan-in of 10 rewired: objects/shares/trash routes now hold ZERO matter drizzle (via deps.matter);
webdav + archive-processing/purge/save-to-drive/trash-retention usecases + zip/webdav-path repos
repointed. site-public-origin -> domain (pure helpers) + usecase over deps.systemOptions.
services/ now 5->2 (only downloads + the s3 shim remain). 1 new deps field (matter).
typecheck + lint:arch (268 modules, no cycles) + 3810 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(spec): add redirect + download-tasks specs
44 new scenarios traced to existing integration tests. lint:spec: 305 scenarios, all covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): migrate downloads (remote-download) cluster (wave 5)
downloads/{core,mappers,types} (915 lines) -> adapters/repos/{downloader,download-task}
(DownloaderRepo + DownloadTaskRepo) + usecases/downloads.ts (assignment + task state
machine + remote-download credit billing) + usecases/ports/downloads.ts (DownloadError +
DTOs). Rewired download-tasks/downloaders/events routes + objects.ts upload handlers to
c.get('deps'). 2 new deps fields. services/ now down to ONLY the s3 shim.
typecheck + lint:arch (268 modules) + 3810 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(spec): add shares spec (32 scenarios)
lint:spec: 337 scenarios, all covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): delete the s3 shim — services/ is empty, clean-arch complete
Routed all 20 S3 call-sites in http (objects/webdav routes + share-utils consumers
shares/redirect/ihost/image-hosting-domain) onto c.get('deps').s3; webdav's no-c helpers
take an S3Gateway param. Repointed 17 test files off the shim onto adapters/gateways/s3.
Deleted server/services/s3.ts — server/services/ is now empty and gone.
Ratchet: dropped ^server/services (fully migrated); no-circular now fully enforced with
no path exemptions. MIGRATION_PENDING is down to 2 deliberately-deferred files
(http/webdav.ts listDescendants, middleware/auth.ts session lookup).
Also adds the objects spec (39 scenarios) -> 376 scenarios across 26 capabilities.
Final gates: typecheck + lint:arch (267 modules, no cycles) + lint:spec (376) + lint
+ 3810 tests + 57 cf-tests all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): migrate the last 2 ratchet files — architecture fully locked
webdav.ts + middleware/auth.ts were the last files touching drizzle outside repos.
- WebDAV: listDescendants/PROPPATCH-touch/PUT-overwrite/COPY-rollback + Basic-Auth username
check moved to MatterRepo.{listActiveDescendants,trashByIds,restoreActiveByIds,touch,applyUpload}
+ UserAdminRepo.{isBanned,matchesUsername}. webdav.ts now imports no drizzle.
- Auth middleware: disabled-user (banned) check -> deps.userAdmin.isBanned.
Ratchet (MIGRATION_PENDING) is now empty and removed. no-circular + drizzle-only-in-repos
are fully enforced with zero exemptions; only platform/, test/, auth.ts remain as permanent
named exceptions. New methods covered by existing real-D1 webdav/auth integration tests.
typecheck + lint:arch (267 modules) + lint:spec (376) + lint + 3810 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(spec): spec the 4 remaining admin/auth capabilities
Closes the spec gaps for capabilities that had routes+tests but no .feature:
image-hosting-config (domain/CF custom-hostname admin), licensing-admin (cloud
pairing/binding/refresh), teams-admin (team admin + entitlements), auth-username
(username sign-up). 42 new scenarios traced to existing integration tests.
lint:spec: 418 scenarios across 30 capabilities, all covered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(matter): listActiveDescendants uses exact-prefix (SUBSTR) not LIKE
Folder names can contain '_'/'%', which LIKE treats as wildcards and would
over-match descendants in WebDAV recursive COPY/MOVE. Reuse the repo's existing
descendantParentCondition (SUBSTR), consistent with getDescendants/cascadeParentPath.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): address review follow-ups (DTO dedupe, composition, dead locals)
- Dedupe transitional DTOs: ShareMatterRow + WebDavMatterRow -> the canonical Matter
port DTO (removes hand-copied duplicates + schema-drift risk; no cycle reintroduced).
- composition.ts: hoist shared stateless instances (one s3/storages/systemOptions
instead of constructing duplicates inline).
- Remove the 21 dead 'const db = c.get(platform).db' locals -> biome warning-free.
typecheck + lint:arch (267 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): dissolve server/licensing into domain + usecases layers
server/licensing/ was a feature-grouped dir outside the layer taxonomy — its 3
orchestration files imported adapters directly, escaping usecases-no-infrastructure.
Now classified + enforced:
- public-keys -> domain/license-keys (pure)
- verify + cloud-event-token -> usecases/license-certificate (paseto/zod crypto helpers)
- entitlement/instance-info/refresh -> deps-first usecases (license-entitlement,
instance-info, license-refresh), using existing deps.{licenseBinding,instance,licensingCloud}
11 consumers rewired to deps; dead db param dropped from runLicensingRefresh. No barrel
changes. server/licensing/ deleted — every server file now sits in an enforced layer
(or a named exception: platform/test/auth.ts/lib/middleware).
typecheck + lint:arch (266 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green.
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>
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>
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>
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.