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.
Use metadata-aware personal workspace detection across org repositories and UI, generate personal/team slugs with the new default random rules, and remove user-managed team slug fields.
Encode WebDAV path segments consistently so Finder follows workspace names such as Ambor's Space via %27 instead of XML-escaped apostrophes.
* 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(api): RESTful resource-oriented API — drop /admin, status sub-resources, merge audience-split routers
Reorganize the entire HTTP surface around resource abstraction instead of
business/audience abstraction.
- Auth: authMiddleware is now soft + global for /api/*; gating is per-route
(requireAuth/requireAdmin/requireTeamRole), so one resource path serves
public, user, and admin callers (no security change — guards moved, not dropped).
- Drop /admin from URLs; merge audience-split routers into one resource each
(announcements, auth-providers, users, teams, quotas, invite-codes,
site-invitations, downloaders, branding, audit).
- State transitions -> PUT /:id/status: objects (confirm/trash/restore),
download-tasks (pause/resume/cancel), background-jobs, image-hosting confirm.
- Verbs -> noun sub-resources: objects/:id/copies, download-tasks/:id/attempts,
background-jobs/:id/retries, site-invitations/:id/deliveries,
licensing/pairings + /pairings/:code + refresh-runs, teams/:id/invite-links.
- Config -> /api/site/* (branding, email, options, instance, changelog);
ihost -> image-hosting; me + profiles + admin/users -> one /api/users
(the :username slot also resolves the internal id, so the admin UI is unchanged).
- External downloader OpenAPI contract cut over in lockstep.
Frontend (rpc.ts + api.ts) and all integration/CF/unit tests updated to match.
Typecheck (server + src), lint:http, biome, and all 4394 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(downloader): regenerate Go client + sync spec for the new RESTful contract
The Go downloader agent (cmd/) and the BDD spec live in this repo, so they must
move with the API:
- Regenerate docs/openapi/downloader.json and cmd/internal/openapi/client.gen.go
from the updated server OpenAPI.
- Update the hand-written Go client: heartbeat -> /downloaders/me/heartbeats,
register -> /downloaders, object confirm -> PUT /objects/:id/status, upload
complete -> PUT .../status, abort -> DELETE .../uploads/:sid. Drop the now-dead
union helpers (jsonBody/decodeJSON) and the bytes import.
- spec: drop the obsolete teams invite-token-missing scenario (the route is now
a path param) and add the auth-providers anon-public-list scenario (the merged
GET serves the public list to anonymous callers).
gofmt clean, go test (121) pass, lint:spec passes (418 scenarios covered).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(api): cover users admin detail/entitlements + getUser wrapper
Close the patch-coverage gaps from the users-resource merge: add integration
tests for GET /api/users/:id (admin detail, success + 404) and
GET /api/users/:id/entitlements (success + 404), and a unit test for the
getUser() api.ts wrapper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(e2e): update Playwright specs + global setup to the new RESTful paths
The e2e specs make direct API calls / response matchers that bypass the SPA, so
they need the new paths too: global-setup storage+options seeding
(/api/storages, /api/site/options), image-host (/api/image-hosting, confirm via
PUT /images/:id/status), object confirm in archive (PUT /objects/:id/status),
announcements and site-invitations (/api/announcements, /api/site-invitations,
/api/site/email). The cloud pairing action:'approve' is the external cloud API,
left as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(e2e): fix cloud-store instance pairing path to /api/licensing/pairings
The cloud-store spec calls the INSTANCE pairing endpoint directly:
POST /api/licensing/pair -> /api/licensing/pairings and the poll
GET /api/licensing/pair/:code/poll -> GET /api/licensing/pairings/:code.
/api/licensing/status and /binding are unchanged; /api/pairings is the
external cloud API, left as-is.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(api): rename /api/site-invitations to /api/invitations
Avoids visual proximity with the /api/site/* config namespace. Top-level
/api/invitations is unambiguous — team invitations are nested under
/api/teams/:id/invitations and invite codes under /api/invite-codes. URL-only
change; the internal site-invitations naming stays (still the accurate concept).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(api): group resources by functional domain (URLs)
Move non-core resources under functional-domain prefixes (not permission):
- /api/site/* absorbs storages, auth-providers, audit-events, licensing,
invitations, invite-codes (joining branding, email, options, instance, changelog)
- /api/downloads/* = tasks + downloaders (regenerated OpenAPI + Go client)
Core resources stay top-level. Updates app.ts, rpc.ts, OpenAPI doc + Go agent
client, and all integration/CF/e2e tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): mirror functional-domain grouping in http/ and usecases/ dirs
Reorganize source files to match the functional URL domains established for
the routes, so the directory tree reflects the same grouping as the API:
- http/{site,downloads,image-hosting}/ and usecases/{site,downloads,image-hosting}/
- dissolve the permission-based console/ dir — admin resources are grouped by
domain (site), not by audience
- console/user -> top-level (users is a core resource, not an admin-only one)
Co-located tests move with their sources; relative imports and vi.mock paths
updated for the new depths. Pure file/directory restructure, no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): finish structural cleanup — merge split admin routers, drop rename leftovers
Three follow-ups from the directory-structure review, completing the
one-file-per-resource and domain-named-file conventions:
- Merge the last two audience-split router files into their resource file as a
second export (matching branding/quotas/invite-codes/site-invitations):
teams-admin.ts -> teams.ts (adminTeams), licensing-admin.ts -> licensing.ts
(licensing + licensingAdmin).
- Drop pre-rename filename leftovers now that the dirs carry the domain:
http/image-hosting/{ihost,ihost-config} -> {images,config};
http/site/site-invitations -> invitations;
usecases/site/{site-invitation,site-public-origin} -> {invitation,public-origin};
usecases/image-hosting/{image-hosting,image-hosting-config} -> {images,config}.
- Group the loose store helpers under the store domain:
http/{cloud-store-helpers,traffic-metering-utils} -> http/cloud-store/{helpers,traffic-metering}.
Routes and exports unchanged; pure file/structure move. tests + co-located
specs move with their sources. No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(api): move announcements under /api/site, co-locate stray tests
Announcements is instance-level, admin-authored content (like branding) — a
site resource, not a top-level one. Move it under the site domain:
- /api/announcements -> /api/site/announcements (mount, RPC base path, api.test, e2e spec)
- http/announcements -> http/site/announcements; usecases/announcement -> usecases/site/announcement
Co-locate the tests that drifted from their sources during the dir reorg
(the 1:1-paired cf-test/unit tests belong next to what they exercise):
- http/storages.cf-test.ts -> http/site/ (next to storages.ts)
- usecases/{license-certificate,license-policy,license-refresh,licensing-admin}.test
-> usecases/site/ (next to the licensing usecase; imports simplified to ./licensing)
No behavior change beyond the announcements path. Routes/exports otherwise stable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(usecases): de-fragment the users and objects domains at the usecase layer
The HTTP layer already serves these as single resources; consolidate their
usecases to match, removing leftover files that mirrored the old split:
- Fold me.ts (avatar) + profile.ts (public lookup) into user.ts — one user
usecase with self/public/admin sections; drop the stale /api/me/avatar and
/api/profiles/:username doc comments. Their unit tests move into user.test.ts.
- Fold matter.ts (confirmUpload, draft→active) into object.ts — the objects
domain is now under one "object" name (the Matter *type* stays in ports/).
Importers updated; no behavior change. server tsc + lint:http + lint:spec clean;
Node 4337 / CF 57 / libsql 6 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(usecases): fold sub-concern usecases into their resource (one file per resource)
Consolidate the usecase layer so each resource is a single source file:
- object.ts absorbs object-upload-session, purge, and save-to-drive (its
upload-session / recursive-purge / save-to-drive sub-concerns)
- share.ts absorbs share-notification and share-ref
External importers re-pointed (trash, redirect, entry-node, workers/scheduled,
http/share-utils, and the surviving integration/cf tests). share.ts now pulls
copyMatterToOrg/saveShareToDrive from object. share.test.ts asserts the real
notification+email fan-out now that dispatchShareCreated is intra-module.
Shared domain services (storage-usage, cloud-traffic-metering, captcha) stay
separate — they're used by many resources. 5 files removed; no behavior change.
Node 4337 / CF 57 / libsql 6 green; tsc + lint:http + lint:spec clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(http): collapse concern-split integration tests into one per resource
Each resource now has a single Node integration test file; the scenario-split
files fold into their resource's main:
- objects-quota + object-multipart-live -> objects.integration.test.ts
- me + profile -> users.integration.test.ts
- quotas-listing -> quotas.integration.test.ts
- teams-admin -> teams.integration.test.ts
- share-public -> shares.integration.test.ts (share-public.cf-test stays — CF runtime)
Helpers de-duplicated or scoped per describe; all [spec:] breadcrumbs preserved
(lint:spec still 418). 7 files removed, all 4337 tests retained. The multipart-live
block now restoreAllMocks so it exercises the real S3 gateway (latent bug fixed).
Node 4337 / CF 57 / libsql 6 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: finish test-file reorg + convert cloud licensing to a real Playwright e2e
Directory grouping (finishing the reorg): auth tests -> http/auth/, cloud-store
test -> cloud-store/, captcha + signup-mode -> usecases/site/ (with import-depth
fixes the moves needed).
One file per resource at the test layer:
- save-to-drive.integration + purge.integration -> object.integration.test.ts
- save-to-drive.cf-test -> object.cf-test.ts
- share-notification.integration -> share.integration.test.ts
- webdav.e2e (a vitest integration test, not Playwright) -> merged into
webdav.integration.test.ts
Cloud licensing e2e: e2e-cloud-integration.test.ts was a vitest file mostly
duplicating existing integration coverage (licensing-admin.integration +
licensing-cloud.test) and the pairing e2e already in cloud-store.spec.ts.
Replaced with a real Playwright e2e (e2e/licensing.spec.ts): pair+approve ->
assert a Pro gate opens -> unbind -> assert it closes. Shared pairing helpers
extracted to e2e/helpers.ts (cloud-store.spec now imports them). run-cloud-e2e
runs both cloud specs in one tunnel; CI grep-invert excludes the new title from
the no-cloud run.
tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): move the cloud-store domain under store/ (matches /api/store)
Following the dir move: http/cloud-store/* -> http/store/*, the cloud-store +
cloud-traffic-metering usecases -> usecases/store/, and the top-level
cloud-traffic-metering http integration test -> http/store/. The http/cloud-store.ts
barrel now re-exports from ./store/*. All importers + moved-file imports rewired.
tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(server): drop the cloud- prefix under store/ now that the dir carries it
- usecases/store/cloud-store -> store.ts; cloud-traffic-metering -> traffic-metering.ts
- http/store/cloud-store.integration -> store.integration; cloud-traffic-metering
.integration -> traffic-metering.integration
- the http barrel http/cloud-store.ts -> http/store/index.ts (re-exports from
./storefront + ./webhooks); app.ts imports './http/store'
store/ is now uniformly named (storefront/webhooks/helpers/shared/traffic-metering
+ store + index). tsc + lint:http + lint:spec clean; Node 4337 / CF 57 / libsql 6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(e2e): licensing spec asserts the bind/unbind lifecycle, not a pro-only gate
The cloud E2E account is business-tier; its pairing certificate does not grant
open_registration (that's why the old vitest test seeded a local pro cert for
that assertion). Assert the edition-agnostic licensing lifecycle instead:
pairAndApprove (binds + waits active) -> unbind -> /status reports bound:false.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group all license application logic into a single usecases/licensing.ts
(certificate/token verification, binding-state, cloud refresh, license-gated
policy) and collapse small single-purpose usecases that belonged together.
- delete license-entitlement.ts: a write-only cache nobody read (loadEntitlement
had zero live consumers); remove its invalidate* call-sites
- merge licensing-refresh-runner -> license-refresh, then fold
license-certificate + license-refresh + license-policy + binding-state into
one licensing.ts (internal cert<-state<-refresh<-policy edges become in-file)
- merge team-count + signup-mode -> license-policy (then into licensing.ts)
- merge trash-retention -> purge (manual purge + scheduled retention sweep)
Tests follow the source: the runner tests are rewritten against a fake
LicensingCloud port (the old module-spy on performRefresh can't survive a
same-module call), and the team-limit test seeds a real pro license instead of
mocking the licensing module. Net 486+/861-. typecheck clean; unit+integration
green (158 files / 3797 tests).
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>
---------
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>
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>
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>
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.
* feat: expand audit event coverage across all state-changing operations
Add audit events for share lifecycle, object operations, team management,
admin/system mutations, storage, quotas, invite codes, site invitations,
users, licensing, and branding.
New events recorded:
- Share: share_create, share_revoke
- Object: upload_confirm, upload_cancel, object_copy, object_purge,
batch_trash, batch_purge, trash_empty
- Team/org: team_invite_link_create, team_member_join, team_logo_update,
team_logo_delete
- System: system_option_set, system_option_delete
- Storage: storage_create, storage_update, storage_delete
- Quota: quota_update
- Invite codes: invite_code_generate, invite_code_delete
- Site invitations: site_invitation_create, site_invitation_revoke
- Users: user_disable, user_enable, user_delete
- Licensing: license_pair, license_disconnect
- Branding: branding_update, branding_reset
Auth sign-up/sign-in events: not logged. The Better Auth hook lifecycle
does not provide reliable org context at sign-up (personal org is created
after the user.create.after hook), and sign-in cannot be distinguished
from normal session refresh without brittle internal request parsing.
No schema migration required; existing activityEvents table fields
support all new event types.
Add i18n translations (en + zh) for all new action names and target types.
Add 26 integration tests verifying every new event category and asserting
no secrets are stored in metadata.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
* feat: add missing audit event coverage for share_download, org lifecycle, license_refresh
Fill gaps identified in code review:
1. share_download: record after successful file download via public share
endpoint (publicShares GET /:token/objects/:ref). Authenticated viewer
is used as actor; share creator is proxy for anonymous downloads with
anonymous:true in metadata. Presigned URL never stored.
2. team lifecycle via Better Auth organizationHooks (server/auth.ts):
- team_member_remove (afterRemoveMember): member.userId used as actor
since BA hook does not expose the initiator
- team_member_role_update (afterUpdateMemberRole): same limitation,
member.userId used; previousRole/newRole recorded in metadata
- team_settings_update (afterUpdateOrganization): actor user available
- team_delete (afterDeleteOrganization): actor user available
- team_member_join via email invite (afterAcceptInvitation): joins
existing link-based join coverage in teams.ts
3. license_refresh: record after POST /api/licensing/refresh succeeds
(licensing-admin.ts)
Unhookable events (documented with code references):
- invite_code_redeem: called at server/auth.ts:269 in user.create.after;
personal org (required for activityEvents.orgId NOT NULL) is created in
session.create.before which runs after this hook. Better Auth provides
no shared state between these hooks.
- site_invitation_accept: called at server/auth.ts:275 in user.create.after;
same orgId unavailability issue.
Add 7 new integration tests for: share_download (×2, authed + anonymous),
team_settings_update, team_member_remove, team_member_role_update,
team_delete, license_refresh.
Add i18n translations (en + zh) for new actions:
share_download, team_member_remove, team_member_role_update,
team_settings_update, team_delete, license_refresh.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add COMMUNITY_TEAM_LIMIT=3 to shared/constants so both server and frontend share one source of truth
- Add server/services/team-count-guard.ts: countUserOrgs + checkTeamLimit (consults licensing state)
- Hook into better-auth organization.beforeCreateOrganization to throw 402 when limit is reached
- Update /teams UI: isAtLimit guard (with loading protection) shows ProBadge on "New Team" button and opens UpgradeHint dialog instead of create dialog when user is at limit
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- Add server/services/signup-mode-guard.ts: getEffectiveSignupMode()
applies Pro check when stored mode is 'open'; non-Pro falls back to
invite-only so downgraded instances stay secure
- Update server/auth.ts: replace internal getSignupMode() with
getEffectiveSignupMode() from new service
- Update server/routes/system.ts: PUT auth_signup_mode=open returns 402
feature_not_available when open_registration feature is absent
- Update RegistrationModeSection: 'open' radio disabled with ProBadge
for non-Pro; clicking it opens UpgradeHint dialog instead of saving
- Add integration tests: 6-combination matrix (3 modes × 2 plans) plus
admin API guard tests
- Update auth.integration.test.ts: split open-mode tests for Pro/non-Pro
Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f
Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
- Allow empty Referer in allowlist check — matches industry standard
(Cloudflare, AWS, etc.): anti-hotlinking targets other websites, not
direct access from tools/address bar
- Support explicit 'path' field in JSON base64 uploads (uPic)
- Set global API key rate limit: 60 req / 60s window
- Remove temporary debug logging
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bug 1: server/services/s3.ts — putObject passed Web ReadableStream to
AWS SDK which only accepts Node Readable or Uint8Array. Convert to
Uint8Array via Response.arrayBuffer() for cross-runtime compatibility.
Bug 2: server/routes/ihost.ts + shared/schemas — presign endpoint
returned 400 (zod validation) instead of 413 for oversized files.
Moved size check from schema .max() to handler with proper 413 status.
Bug 3: src/lib/api.ts — createIhostApiKey sent `permissions` in the
request body, but better-auth's apiKey plugin rejects client-set
permissions (SERVER_ONLY_PROPERTY). Removed it; server defaultPermissions
handles it automatically.
Also: session.create.before now checks org existence by slug before
creating, preventing UNIQUE constraint failures when membership was
revoked but org still exists.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
better-auth defers `user.create.after` hooks until after the transaction
commits, but the session cookie cache is written inside the transaction.
This caused `activeOrganizationId` to be null on first load after sign-up,
breaking features that depend on an active organization (e.g. Image Host).
Solution: create the personal org in `session.create.before` (which runs
inside the transaction, after user INSERT) and set `activeOrganizationId`
on the session before it's cached. Also keep idempotent org creation in
`user.create.after` so orgs are created even without auto sign-in (e.g.
when email verification is required).
Additionally fix an ambiguous Playwright locator in image-host.spec.ts
that matched multiple elements.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Users signing up via social login (OAuth/OIDC) don't provide a username.
Generate one automatically from the email prefix, with random suffix
fallback for conflicts or short prefixes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>