* feat: attribute files and downloads to actors
* chore: refresh preview after staging migration
* fix: use a dash for missing actor identity
* fix: complete actor attribution and file creator UI
* fix: refine file creator identity UI
* feat(ids)!: normalize persistent identifiers to Base62
BREAKING CHANGE: historical ZPan-owned IDs and public tokens require the one-time normalization migration; old public links and stored credentials are invalidated without runtime fallback.
* fix(ids): namespace public redirect tokens
Generate share tokens as s plus 11 Base62 characters and image tokens as i plus 11 Base62 characters. Dispatch /r by namespace prefix, reuse the single resolved record, and update migration validation, contracts, documentation, and tests.
* fix(ids): guard future object storage keys
* fix(ids): normalize historical references safely
Centralize request audit recording, preserve immutable download-task history, and derive hourly statistics and backfills from the same authoritative sources. Add durable user registration facts so admin deletion no longer destroys signup history.
Retain purged matter and image metadata while hiding tombstones from live reads. Record exact storage deltas and build hourly trends only after the opening balance.
Serve canonical root-relative DAV URLs on a configured hostname while preserving the existing /dav mount. Reconcile Cloudflare rewrite rules and Worker custom domains during fork deployments, and expose the effective URL through site options.
Build versioned UTC hourly rollups, validate analytics facts, and keep dashboard requests result-only with explicit coverage semantics.
Move derived metrics to the server and simplify the admin dashboard around one reconcilable UTC range without export.
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.
* 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>