refactor(server): clean architecture migration (hono-cf-clean-arch) (#433)

* 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>
This commit is contained in:
Jasper Van
2026-06-13 14:56:24 -04:00
committed by GitHub
parent 6521d1b722
commit 191ee0a07d
349 changed files with 15054 additions and 9560 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* Architecture enforcement for the hono-cf-clean-arch layout.
*
* pnpm lint:arch
*
* The hono-cf-clean-arch migration is COMPLETE (see docs/clean-arch-migration.md):
* server/services/ is gone, every route uses `c.get('deps')`, and the ratchet that
* tracked not-yet-migrated drizzle importers is empty and removed. All rules below
* are fully enforced with no migration allowlist.
*
* Permanent exceptions to `drizzle-only-in-repos`:
* - server/db : the schema + client live here by definition
* - server/platform : the runtime DB/env/binding abstraction owns the
* `Database` driver type
* - server/auth.ts : better-auth owns its own tables and serves requests
* - server/test : test harness/helpers (the suites are exempt anyway)
*/
// MIGRATION COMPLETE — the ratchet is empty and gone. Persistence is confined to
// adapters/repos/ + db/, plus three permanent exceptions: platform/ (owns the
// `Database` driver type), the test harness, and auth.ts (better-auth owns its own
// tables and handles raw requests). The architecture is now fully locked.
const DRIZZLE_ALLOWED = `^server/(adapters/repos|db|platform|test)|^server/auth\\.ts`
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: 'no-circular',
// Fully enforced: the legacy services/ dir (which carried a pre-existing
// user <-> org-entitlements cycle) is migrated and gone, so no path is exempt.
severity: 'error',
from: {},
to: { circular: true },
},
{
name: 'domain-stays-pure',
comment: 'domain/ may only import domain/ and shared/. No frameworks, no I/O.',
severity: 'error',
from: { path: '^server/domain' },
to: { pathNot: '^server/domain|^shared' },
},
{
name: 'usecases-no-infrastructure',
comment: 'usecases/ must not reach outward to adapters, http, db, or composition.',
severity: 'error',
from: { path: '^server/usecases' },
to: { path: '^server/(adapters|http|db)|^server/composition' },
},
{
name: 'usecases-no-framework-packages',
comment: 'usecases/ must not import delivery or persistence frameworks.',
severity: 'error',
from: { path: '^server/usecases' },
to: { path: 'node_modules/(hono|drizzle-orm|better-auth)' },
},
{
name: 'adapters-not-into-delivery',
comment: 'adapters/ implement ports; they never know about http/ or composition.',
severity: 'error',
from: { path: '^server/adapters' },
to: { path: '^server/(http|composition)' },
},
{
name: 'drizzle-only-in-repos',
comment: 'Persistence is confined to adapters/repos/ and db/ (+ ratchet allowlist).',
severity: 'error',
from: { path: '^server', pathNot: DRIZZLE_ALLOWED },
to: { path: 'node_modules/drizzle-orm|^server/db/(schema|auth-schema)' },
},
{
name: 'http-not-into-adapters',
comment: 'http/ gets dependencies from context, never constructs adapters.',
severity: 'error',
from: { path: '^server/http' },
to: { path: '^server/adapters' },
},
{
name: 'shared-is-a-leaf',
comment: 'shared/ is the contract; it imports nothing from server/ or src/.',
severity: 'error',
from: { path: '^shared' },
to: { path: '^server|^src' },
},
{
name: 'frontend-not-into-server',
comment: 'The SPA talks to the server over HTTP only.',
severity: 'error',
from: { path: '^src' },
to: { path: '^server' },
},
{
name: 'server-not-into-frontend',
comment: 'The server never reaches into the SPA; the two halves meet only through shared/.',
severity: 'error',
from: { path: '^server' },
to: { path: '^src' },
},
],
options: {
doNotFollow: { path: 'node_modules' },
exclude: { path: ['\\.(test|spec)\\.[jt]sx?$', '\\.(integration|cf-test|libsql-test)\\.[jt]sx?$', '\\.gen\\.[jt]s$'] },
tsConfig: { fileName: 'tsconfig.depcruise.json' },
tsPreCompilationDeps: true,
},
}
+2
View File
@@ -55,6 +55,8 @@ jobs:
cache-dependency-path: cmd/go.sum
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm lint:arch
- run: pnpm lint:spec
- run: pnpm typecheck
- run: pnpm openapi:downloader:check
- run: pnpm exec vitest run --project unit --coverage --coverage.reportsDirectory=coverage/unit
+117
View File
@@ -0,0 +1,117 @@
# Clean Architecture Migration (hono-cf-clean-arch)
Living tracker for migrating `server/` to the canonical layout: `domain → usecases
→ adapters → http`, with a `composition.ts` root and `dependency-cruiser`
enforcement. Strangler-style: **every commit is behavior-preserving and leaves
`pnpm typecheck` + `pnpm test` green.** One PR, many green commits.
## Target layout
```
server/
domain/ pure business rules (no node_modules except shared/)
usecases/ application operations; take `deps` first
ports.ts barrel re-exporting ports/<resource>.ts
ports/ framework-free port interfaces + DTOs (one file per resource)
deps.ts the Deps aggregate
adapters/
repos/ drizzle repositories (the ONLY place schema/drizzle is imported)
gateways/ external services that aren't HTTP-API providers (s3, email, …)
providers/ external HTTP API clients
http/ hono routes (already split per resource); get deps from context
auth.ts better-auth (named drizzle exception)
composition.ts createDeps(platform): the only place adapters are constructed
db/ drizzle schema + client
```
## Conventions (the recipe applied per resource)
1. Port: `usecases/ports/<r>.ts` — plain DTOs + repo/gateway interface. No drizzle,
no zod runtime (type-only shared imports OK). Add `export * from './ports/<r>'`
to `usecases/ports.ts`.
2. Adapter: `adapters/repos/<r>.ts``create<R>Repo(db): <R>Repo`. Maps rows → DTOs.
3. Wire: add to `usecases/deps.ts` (the `Deps` interface) and `composition.ts`.
4. Rewire callers:
- `http/` + middleware → `c.get('deps').<r>.<method>(...)`
- not-yet-migrated `services/` / `auth.ts``create<R>Repo(db).<method>(...)`
(transitional; removed when that service itself migrates)
5. Delete the old `services/<r>.ts`; move its co-located data tests next to the repo.
6. `pnpm typecheck && pnpm lint && pnpm test` green; commit.
Imports: relative within `server/` (matches existing code); `@shared/*` for shared.
## Progress
### Done
- [x] **Step 1** `routes/ → http/` rename
- [x] **Backbone** `composition.ts` + `usecases/deps.ts` + `usecases/ports/` + deps middleware
- [x] `activity``adapters/repos/activity.ts` (ActivityRepo)
- [x] `storage``adapters/repos/storage.ts` (StorageRepo)
- [x] `profile` → ProfileRepo; `buildBreadcrumb``domain/breadcrumb.ts`
- [x] `announcement` → AnnouncementRepo
- [x] `notification` → NotificationRepo
- [x] **test infra**: `createApp(platform, auth, deps?)` + `createTestApp` returns
`deps`, so tests fake a port by spying on `testApp.deps.<x>` (replaces
cross-boundary module spies, e.g. events SSE unread-count failure)
- [x] `org` → OrgRepo (authz queries) · `invite` → InviteRepo
- [x] `background-jobs` → BackgroundJobRepo (+ BackgroundJobError to ports)
- [x] `effective-quota` → QuotaRepo (`currentTrafficPeriod` → domain/quota); unblocks
team / storage-usage / matter / cloud-traffic-metering. 14 callers + entry-node
+ workers/scheduled rewired.
- [x] `team` → TeamRepo (uses QuotaRepo internally) · `team-invite` → TeamInviteRepo
- [x] `site-invitations` → SiteInvitationRepo · `cf-custom-hostnames` → CfHostnamesProvider
· `changelog` → ChangelogProvider · `instance`/license-binding → InstanceRepo/LicenseBindingRepo
· `s3` → S3Gateway (shim) · `system-options` → SystemOptionsRepo · `image-hosting-config` → ImageHostingConfigRepo
- [x] `user` + `org-entitlements` -> UserAdminRepo (combined; resolves the user/org-entitlements cycle)
- [x] `storage-usage` → StorageUsageRepo + reserve/withReservation usecase (the quota-reservation crown foundation)
### Parallel migration waves (file-disjoint components migrated concurrently by subagents; barrels wired by the orchestrator)
All waves done. Each wave: agents migrated disjoint components (new ports/adapters/usecases/domain,
rewired callers to `c.get('deps')`), the orchestrator wired the 3 barrels and ran the gates.
- [x] **Wave 1** — instance-telemetry, image-upload, archive-jobs, zip-compress/extract, object-upload-sessions, purge
- [x] **Wave 2** — auth-account (signup-mode-guard, team-count-guard, captcha, email, share-notification),
webdav-middleware (download-tokens, api-keys, webdav-state/path/xml), cloud (licensing-cloud, cloud-store,
cloud-traffic-metering→cloud-traffic-report, remote-download-usage, licensing-refresh-runner), branding, image-hosting
- [x] **Wave 3** — share + save-to-drive, archive-processing (+archive-target-folder), trash-retention
- [x] **Wave 4** — the **matter keystone** (MatterRepo + matter usecase + matter-name-conflict→domain) + site-public-origin
- [x] **Wave 5** — downloads (DownloaderRepo + DownloadTaskRepo + state-machine usecase); then the **s3 shim deleted**
### Status: COMPLETE — architecture fully locked
`server/services/` is empty and removed. Every drizzle access lives in `adapters/repos/`; every route
gets its dependencies from `c.get('deps')`. **The ratchet is empty and removed** — including the final two
files: `http/webdav.ts` (listDescendants/proppatch/PUT-overwrite/Basic-Auth → MatterRepo.{listActiveDescendants,
trashByIds,restoreActiveByIds,touch,applyUpload} + UserAdminRepo.{isBanned,matchesUsername}) and
`middleware/auth.ts` (disabled-user check → UserAdminRepo.isBanned). Gates green: `typecheck`,
`lint:arch` (267 modules, **no-circular + drizzle-only-in-repos fully enforced, zero exemptions**),
`lint:spec`, `lint`, `test` (3810), `test:cf` (57).
### Enforcement — DONE, fully locked
- [x] `.dependency-cruiser.cjs` + `lint:arch` in CI. All clean-arch rules active with **no migration allowlist**.
- [x] `platform/` (Database driver type), `server/test`, and `auth.ts` (better-auth owns its tables) are the only
permanent named exceptions to `drizzle-only-in-repos`.
### Product specs (BDD-lite) — DONE
- [x] `spec/` Gherkin `.feature` (one per capability) + `spec/README.md`; `[spec: <id>]` breadcrumbs on home
tests; `pnpm lint:spec` (CI) enforces traceability both ways.
- [x] **418 scenarios across 30 capabilities**: storages, announcements, notifications, invite-codes,
site-invitations, quotas, profile, licensing, users, audit, teams, avatar, background-jobs, events, health,
branding, email-config, auth-providers, system, image-hosting, webdav, quota-store, redirect, download-tasks,
shares, objects, image-hosting-config, licensing-admin, teams-admin, auth-username.
### Structure cleanup — DONE
- [x] Dissolved the unclassified `server/licensing/` feature dir into the layers (it had escaped
`domain-stays-pure` / `usecases-no-infrastructure`): `public-keys``domain/license-keys`;
`verify`+`cloud-event-token``usecases/license-certificate`; `entitlement`/`instance-info`/`refresh`
deps-first usecases (`license-entitlement`/`instance-info`/`license-refresh`, using existing
`deps.{licenseBinding,instance,licensingCloud}` — no barrel changes). 11 consumers rewired to `deps`.
Remaining non-layer dirs are intentional: `platform/`+`test/`+`auth.ts` (named exceptions),
`lib/` (framework-free utils), `middleware/` (Hono delivery convention).
### Post-review follow-ups — DONE
- [x] Deduped the transitional matter-row DTOs: `ShareMatterRow` / `WebDavMatterRow` now reference the
canonical `Matter` port DTO (removed the hand-copied duplicates + their schema-drift risk).
- [x] Hoisted shared stateless instances in `composition.ts` (single `s3` / `storages` / `systemOptions`).
- [x] Removed the 21 dead `const db = c.get('platform').db` locals — biome is now warning-free.
- [x] `MatterRepo.listActiveDescendants` uses SUBSTR exact-prefix (not LIKE) — correctness for names with `_`/`%`.
+3
View File
@@ -31,6 +31,8 @@
"test:watch": "vitest --project unit --project integration",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"lint:arch": "depcruise server/ shared/ --config .dependency-cruiser.cjs",
"lint:spec": "node scripts/lint-spec.mjs",
"prepare": "husky",
"format": "biome format --write .",
"e2e": "playwright test",
@@ -115,6 +117,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"@vitest/coverage-v8": "^4.1.2",
"dependency-cruiser": "^17.4.3",
"drizzle-kit": "^0.31.10",
"husky": "^9.1.7",
"jsdom": "^29.0.2",
+255
View File
@@ -224,6 +224,9 @@ importers:
'@vitest/coverage-v8':
specifier: ^4.1.2
version: 4.1.4(vitest@4.1.4)
dependency-cruiser:
specifier: ^17.4.3
version: 17.4.3
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
@@ -3318,6 +3321,22 @@ packages:
'@vitest/utils@4.1.4':
resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==}
acorn-jsx-walk@2.0.0:
resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn-loose@8.5.2:
resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==}
engines: {node: '>=0.4.0'}
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@@ -3335,6 +3354,10 @@ packages:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
@@ -3536,6 +3559,10 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@@ -3580,6 +3607,13 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -3665,6 +3699,11 @@ packages:
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
dependency-cruiser@17.4.3:
resolution: {integrity: sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w==}
engines: {node: ^20.12||^22||>=24}
hasBin: true
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -3818,6 +3857,10 @@ packages:
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@@ -3932,6 +3975,9 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -3957,6 +4003,10 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -3964,6 +4014,10 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-from-html@2.0.3:
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
@@ -4048,15 +4102,27 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ini@4.1.1:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
interpret@3.1.1:
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
engines: {node: '>=10.13.0'}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -4070,6 +4136,10 @@ packages:
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-core-module@2.16.2:
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
engines: {node: '>= 0.4'}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
@@ -4088,10 +4158,18 @@ packages:
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-installed-globally@1.0.0:
resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==}
engines: {node: '>=18'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-path-inside@4.0.0:
resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==}
engines: {node: '>=12'}
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@@ -4154,6 +4232,10 @@ packages:
engines: {node: '>=6'}
hasBin: true
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
@@ -4597,6 +4679,9 @@ packages:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-posix@1.0.0:
resolution: {integrity: sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==}
@@ -4691,6 +4776,10 @@ packages:
promise-limit@2.7.0:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -4829,6 +4918,10 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
rechoir@0.8.0:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'}
refractor@5.0.0:
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
@@ -4841,6 +4934,10 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
regexp-tree@0.1.27:
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
hasBin: true
rehype-attr@4.0.2:
resolution: {integrity: sha512-v4+gw7pvUVLbG/dUpLgBE6r3TWTBYJ7z+sfAH3zapmM5CKzk5+CopFQgr4gMR6OBSKl/qpI6HR7gv1Cbig0uow==}
engines: {node: '>=16'}
@@ -4904,6 +5001,11 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
resolve@1.22.12:
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
engines: {node: '>= 0.4'}
hasBin: true
restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
@@ -4942,6 +5044,9 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-regex@2.1.1:
resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -4996,6 +5101,9 @@ packages:
resolution: {integrity: sha512-5KbjcP456Gm1lrk+rhDuX4zFri+3lRX39IjzXAvoMAO8Ne76WlVlM+Z3kA6jdZ7+QHadgXsf++R7g2jaYVbYig==}
engines: {node: '>=0.12.18'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slice-ansi@7.1.2:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
@@ -5056,6 +5164,10 @@ packages:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
@@ -5082,6 +5194,10 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -5168,6 +5284,14 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tsconfig-paths-webpack-plugin@4.2.0:
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
engines: {node: '>=10.13.0'}
tsconfig-paths@4.2.0:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -5389,6 +5513,11 @@ packages:
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
watskeburt@5.0.3:
resolution: {integrity: sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==}
engines: {node: ^20.12||^22.13||>=24.0}
hasBin: true
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
@@ -8406,6 +8535,20 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
acorn-jsx-walk@2.0.0: {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn-loose@8.5.2:
dependencies:
acorn: 8.16.0
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
ansi-escapes@7.3.0:
@@ -8416,6 +8559,10 @@ snapshots:
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansi-styles@6.2.3: {}
@@ -8576,6 +8723,11 @@ snapshots:
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
@@ -8621,6 +8773,12 @@ snapshots:
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
colorette@2.0.20: {}
comma-separated-tokens@2.0.3: {}
@@ -8685,6 +8843,27 @@ snapshots:
defu@6.1.7: {}
dependency-cruiser@17.4.3:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
acorn-jsx-walk: 2.0.0
acorn-loose: 8.5.2
acorn-walk: 8.3.5
commander: 14.0.3
enhanced-resolve: 5.22.1
ignore: 7.0.5
interpret: 3.1.1
is-installed-globally: 1.0.0
json5: 2.2.3
picomatch: 4.0.4
prompts: 2.4.2
rechoir: 0.8.0
safe-regex: 2.1.1
semver: 7.8.1
tsconfig-paths-webpack-plugin: 4.2.0
watskeburt: 5.0.3
dequal@2.0.3: {}
detect-libc@2.0.2: {}
@@ -8740,6 +8919,8 @@ snapshots:
error-stack-parser-es@1.0.5: {}
es-errors@1.3.0: {}
es-module-lexer@2.1.0: {}
esbuild@0.18.20:
@@ -8933,6 +9114,8 @@ snapshots:
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
gensync@1.0.0-beta.2: {}
get-east-asian-width@1.6.0: {}
@@ -8951,10 +9134,18 @@ snapshots:
dependencies:
is-glob: 4.0.3
global-directory@4.0.1:
dependencies:
ini: 4.1.1
graceful-fs@4.2.11: {}
has-flag@4.0.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
hast-util-from-html@2.0.3:
dependencies:
'@types/hast': 3.0.4
@@ -9119,12 +9310,18 @@ snapshots:
ieee754@1.2.1: {}
ignore@7.0.5: {}
inherits@2.0.4: {}
ini@1.3.8: {}
ini@4.1.1: {}
inline-style-parser@0.2.7: {}
interpret@3.1.1: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@@ -9138,6 +9335,10 @@ snapshots:
is-buffer@1.1.6: {}
is-core-module@2.16.2:
dependencies:
hasown: 2.0.4
is-decimal@2.0.1: {}
is-extglob@2.1.1: {}
@@ -9152,8 +9353,15 @@ snapshots:
is-hexadecimal@2.0.1: {}
is-installed-globally@1.0.0:
dependencies:
global-directory: 4.0.1
is-path-inside: 4.0.0
is-number@7.0.0: {}
is-path-inside@4.0.0: {}
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
@@ -9215,6 +9423,8 @@ snapshots:
json5@2.2.3: {}
kleur@3.0.3: {}
kleur@4.1.5: {}
kysely@0.28.17: {}
@@ -9848,6 +10058,8 @@ snapshots:
path-expression-matcher@1.5.0: {}
path-parse@1.0.7: {}
path-posix@1.0.0: {}
path-to-regexp@6.3.0: {}
@@ -9929,6 +10141,11 @@ snapshots:
promise-limit@2.7.0: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
sisteransi: 1.0.5
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -10129,6 +10346,10 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
rechoir@0.8.0:
dependencies:
resolve: 1.22.12
refractor@5.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -10146,6 +10367,8 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
regexp-tree@0.1.27: {}
rehype-attr@4.0.2:
dependencies:
unified: 11.0.5
@@ -10260,6 +10483,13 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
resolve@1.22.12:
dependencies:
es-errors: 1.3.0
is-core-module: 2.16.2
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
restore-cursor@5.1.0:
dependencies:
onetime: 7.0.0
@@ -10317,6 +10547,10 @@ snapshots:
safe-buffer@5.2.1: {}
safe-regex@2.1.1:
dependencies:
regexp-tree: 0.1.27
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@@ -10391,6 +10625,8 @@ snapshots:
simple-icons@16.18.0: {}
sisteransi@1.0.5: {}
slice-ansi@7.1.2:
dependencies:
ansi-styles: 6.2.3
@@ -10449,6 +10685,8 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-json-comments@2.0.1: {}
strnum@2.3.0: {}
@@ -10477,6 +10715,8 @@ snapshots:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
symbol-tree@3.2.4: {}
tailwind-merge@3.5.0: {}
@@ -10553,6 +10793,19 @@ snapshots:
ts-interface-checker@0.1.13: {}
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.22.1
tapable: 2.3.3
tsconfig-paths: 4.2.0
tsconfig-paths@4.2.0:
dependencies:
json5: 2.2.3
minimist: 1.2.8
strip-bom: 3.0.0
tslib@2.8.1: {}
tsup@8.5.1(@swc/core@1.15.40)(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
@@ -10766,6 +11019,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
watskeburt@5.0.3: {}
web-namespaces@2.0.1: {}
web-streams-polyfill@3.3.3: {}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env node
// Spec ↔ test traceability governance lint (NOT a behavioural test runner).
//
// Every scenario in spec/**/*.feature is tagged `@<capability>/<slug>`. Its home
// test carries `[spec: <capability>/<slug>]` in the test name. This lint enforces
// the link both ways:
// - ERROR: a spec scenario id with no referencing test (spec drift / missing coverage)
// - ERROR: a `[spec: id]` breadcrumb whose id has no scenario (stale reference)
// See spec/README.md.
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
const ROOT = process.cwd()
const SPEC_DIR = join(ROOT, 'spec')
const TEST_DIRS = ['server', 'src', 'shared', 'e2e']
const TEST_RE = /\.(test|integration\.test|cf-test|libsql-test|spec)\.[jt]sx?$/
const ID_RE = /@([a-z0-9-]+\/[a-z0-9-]+)\b/g
const REF_RE = /\[spec:\s*([a-z0-9-]+\/[a-z0-9-]+)\s*\]/g
function walk(dir, onFile) {
let entries
try {
entries = readdirSync(dir)
} catch {
return
}
for (const name of entries) {
if (name === 'node_modules' || name === '.git') continue
const full = join(dir, name)
const st = statSync(full)
if (st.isDirectory()) walk(full, onFile)
else onFile(full)
}
}
// 1. Collect scenario ids declared in the specs.
const specIds = new Map() // id -> feature file
walk(SPEC_DIR, (file) => {
if (!file.endsWith('.feature')) return
const text = readFileSync(file, 'utf8')
for (const line of text.split('\n')) {
// Only tag lines (start with @ after trim) carry scenario ids.
if (!line.trim().startsWith('@')) continue
for (const m of line.matchAll(ID_RE)) specIds.set(m[1], file)
}
})
// 2. Collect [spec: id] breadcrumbs from test files.
const refIds = new Map() // id -> [files]
for (const d of TEST_DIRS) {
walk(join(ROOT, d), (file) => {
if (!TEST_RE.test(file)) return
const text = readFileSync(file, 'utf8')
for (const m of text.matchAll(REF_RE)) {
const list = refIds.get(m[1]) ?? []
list.push(file)
refIds.set(m[1], list)
}
})
}
const orphanSpecs = [...specIds.keys()].filter((id) => !refIds.has(id)).sort()
const staleRefs = [...refIds.keys()].filter((id) => !specIds.has(id)).sort()
if (orphanSpecs.length === 0 && staleRefs.length === 0) {
console.log(`✔ spec traceability: ${specIds.size} scenarios, all covered by tests`)
process.exit(0)
}
if (orphanSpecs.length) {
console.error(`\n${orphanSpecs.length} spec scenario(s) with no [spec: id] test:`)
for (const id of orphanSpecs) console.error(` @${id} (${specIds.get(id).replace(`${ROOT}/`, '')})`)
}
if (staleRefs.length) {
console.error(`\n${staleRefs.length} [spec: id] breadcrumb(s) with no matching scenario:`)
for (const id of staleRefs) console.error(` [spec: ${id}] (${refIds.get(id)[0].replace(`${ROOT}/`, '')})`)
}
process.exit(1)
+96
View File
@@ -0,0 +1,96 @@
import type { Platform } from '../../platform/interface'
import { processArchiveJob } from '../../usecases/archive-processing'
import type { ArchiveJobMessage, ArchiveJobsGateway } from '../../usecases/ports'
import { createArchiveTargetFolderRepo } from '../repos/archive-target-folder'
import { createBackgroundJobRepo } from '../repos/background-job'
import { createMatterRepo } from '../repos/matter'
import { createNotificationRepo } from '../repos/notification'
import { createQuotaRepo } from '../repos/quota'
import { createStorageRepo } from '../repos/storage'
import { createStorageUsageRepo } from '../repos/storage-usage'
import { createZipPlanRepo } from '../repos/zip'
import { S3Service } from './s3'
import { createZipGateway } from './zip'
export const ARCHIVE_QUEUE_BINDING = 'ARCHIVE_QUEUE'
interface QueueProducer {
send(message: ArchiveJobMessage): Promise<void>
}
// When no queue binding is present (Node/dev), jobs run in-process. The queue
// drains on the next tick so dispatch resolves before the work starts, matching
// the fire-and-forget semantics of a Cloudflare Queue producer.
class LocalArchiveQueue {
private readonly pending: ArchiveJobMessage[] = []
private running = false
constructor(private readonly run: (message: ArchiveJobMessage) => Promise<void>) {}
push(message: ArchiveJobMessage): void {
this.pending.push(message)
if (!this.running) setTimeout(() => void this.drain(), 0)
}
private async drain(): Promise<void> {
if (this.running) return
this.running = true
try {
for (;;) {
const next = this.pending.shift()
if (!next) return
try {
await this.run(next)
} catch (error) {
console.error('[archive-jobs] local worker failed:', error)
}
}
} finally {
this.running = false
if (this.pending.length > 0) setTimeout(() => void this.drain(), 0)
}
}
}
export function createArchiveJobsGateway(platform: Platform): ArchiveJobsGateway {
const { db } = platform
// The archive usecase composes existing ports; assemble exactly the subset it
// needs from the platform here (the queue consumer entrypoint), so composition
// can keep constructing this gateway with only the platform.
const deps = {
s3: new S3Service(),
storages: createStorageRepo(db),
quota: createQuotaRepo(db),
storageUsage: createStorageUsageRepo(db),
backgroundJobs: createBackgroundJobRepo(db),
notifications: createNotificationRepo(db),
zip: createZipGateway(),
zipPlan: createZipPlanRepo(db),
archiveTargetFolders: createArchiveTargetFolderRepo(db),
matter: createMatterRepo(db),
}
async function runMessage(message: ArchiveJobMessage): Promise<void> {
await processArchiveJob(deps, {
orgId: message.orgId,
userId: message.userId,
request: message.request,
jobId: message.jobId,
})
}
const localQueue = new LocalArchiveQueue(runMessage)
return {
async dispatch(message) {
const queue = platform.getBinding<QueueProducer>(ARCHIVE_QUEUE_BINDING)
if (queue) {
await queue.send(message)
return
}
localQueue.push(message)
},
runMessage,
}
}
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as schema from '../db/schema.js'
import type { Platform } from '../platform/interface'
import { createTestApp } from '../test/setup.js'
import { getEmailConfig, getEmailSettings, isEmailConfigured, sendEmail } from './email.js'
import { createSystemOptionsRepo } from '../../adapters/repos/system-options.js'
import * as schema from '../../db/schema.js'
import type { Platform } from '../../platform/interface'
import { createTestApp } from '../../test/setup.js'
import { createEmailGateway } from './email.js'
const sendMailMock = vi.fn()
@@ -10,16 +11,39 @@ vi.mock('nodemailer', () => ({
createTransport: vi.fn(() => ({ sendMail: sendMailMock })),
}))
describe('getEmailConfig', () => {
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
function gatewayFor(db: TestDb) {
return createEmailGateway(createSystemOptionsRepo(db))
}
// Binding-free platform — equivalent to the old bare-Database email source.
function bareplatform(db: TestDb): Platform {
return {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(_key: string) => undefined as T | undefined,
}
}
function platformWithBinding(db: TestDb, binding: unknown): Platform {
return {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? (binding as T) : undefined),
}
}
describe('getConfig', () => {
it('throws when email_provider is not set', async () => {
const { db } = await createTestApp()
await expect(getEmailConfig(db)).rejects.toThrow('Email provider not configured')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('Email provider not configured')
})
it('throws when email_from is not set', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values({ key: 'email_provider', value: 'smtp' })
await expect(getEmailConfig(db)).rejects.toThrow('Email sender not configured')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('Email sender not configured')
})
it('throws when SMTP host is missing', async () => {
@@ -28,7 +52,7 @@ describe('getEmailConfig', () => {
{ key: 'email_provider', value: 'smtp' },
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(getEmailConfig(db)).rejects.toThrow('SMTP host and port are required')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
})
it('throws when SMTP port is missing', async () => {
@@ -38,7 +62,7 @@ describe('getEmailConfig', () => {
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_smtp_host', value: 'smtp.example.com' },
])
await expect(getEmailConfig(db)).rejects.toThrow('SMTP host and port are required')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
})
it('returns SMTP config when provider is smtp and all required options are set', async () => {
@@ -52,7 +76,7 @@ describe('getEmailConfig', () => {
{ key: 'email_smtp_pass', value: 'secret' },
{ key: 'email_smtp_secure', value: 'true' },
])
const config = await getEmailConfig(db)
const config = await gatewayFor(db).getConfig(bareplatform(db))
expect(config.provider).toBe('smtp')
expect(config.from).toBe('no-reply@example.com')
if (config.provider !== 'smtp') throw new Error('expected smtp')
@@ -71,7 +95,7 @@ describe('getEmailConfig', () => {
{ key: 'email_smtp_host', value: 'smtp.example.com' },
{ key: 'email_smtp_port', value: '25' },
])
const config = await getEmailConfig(db)
const config = await gatewayFor(db).getConfig(bareplatform(db))
if (config.provider !== 'smtp') throw new Error('expected smtp')
expect(config.smtp.secure).toBe(false)
expect(config.smtp.user).toBe('')
@@ -84,7 +108,7 @@ describe('getEmailConfig', () => {
{ key: 'email_provider', value: 'http' },
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(getEmailConfig(db)).rejects.toThrow('HTTP email url and api_key are required')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('HTTP email url and api_key are required')
})
it('throws when HTTP apiKey is missing', async () => {
@@ -94,7 +118,7 @@ describe('getEmailConfig', () => {
{ key: 'email_from', value: 'no-reply@example.com' },
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
])
await expect(getEmailConfig(db)).rejects.toThrow('HTTP email url and api_key are required')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('HTTP email url and api_key are required')
})
it('returns HTTP config when provider is http and all required options are set', async () => {
@@ -105,7 +129,7 @@ describe('getEmailConfig', () => {
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
{ key: 'email_http_api_key', value: 'my-api-key' },
])
const config = await getEmailConfig(db)
const config = await gatewayFor(db).getConfig(bareplatform(db))
expect(config.provider).toBe('http')
expect(config.from).toBe('no-reply@example.com')
if (config.provider !== 'http') throw new Error('expected http')
@@ -119,7 +143,7 @@ describe('getEmailConfig', () => {
{ key: 'email_provider', value: 'unknown' },
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(getEmailConfig(db)).rejects.toThrow('Unknown email provider: unknown')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('Unknown email provider: unknown')
})
it('throws when cloudflare provider is selected without EMAIL binding', async () => {
@@ -128,13 +152,10 @@ describe('getEmailConfig', () => {
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(_key: string) => undefined as T | undefined,
} satisfies Platform
await expect(getEmailConfig(platform)).rejects.toThrow('Cloudflare email binding "EMAIL" is not configured')
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow(
'Cloudflare email binding "EMAIL" is not configured',
)
})
it('returns Cloudflare config when provider is cloudflare and binding is present', async () => {
@@ -143,20 +164,15 @@ describe('getEmailConfig', () => {
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(_key: string) => ({ send: vi.fn() }) as T,
} satisfies Platform
await expect(getEmailConfig(platform)).resolves.toEqual({
await expect(gatewayFor(db).getConfig(platformWithBinding(db, { send: vi.fn() }))).resolves.toEqual({
provider: 'cloudflare',
from: 'no-reply@zpan.space',
})
})
})
describe('sendEmail — SMTP provider', () => {
describe('send — SMTP provider', () => {
beforeEach(() => {
sendMailMock.mockReset()
})
@@ -172,7 +188,7 @@ describe('sendEmail — SMTP provider', () => {
{ key: 'email_smtp_port', value: '587' },
])
await sendEmail(db, { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
await gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
expect(sendMailMock).toHaveBeenCalledWith({
from: 'no-reply@example.com',
to: 'user@example.com',
@@ -182,7 +198,7 @@ describe('sendEmail — SMTP provider', () => {
})
})
describe('sendEmail — HTTP provider', () => {
describe('send — HTTP provider', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
@@ -200,7 +216,7 @@ describe('sendEmail — HTTP provider', () => {
{ key: 'email_http_api_key', value: 'my-api-key' },
])
await sendEmail(db, { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
await gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
expect(fetchMock).toHaveBeenCalledWith('https://api.mail.example.com/send', {
method: 'POST',
@@ -234,13 +250,13 @@ describe('sendEmail — HTTP provider', () => {
{ key: 'email_http_api_key', value: 'my-api-key' },
])
await expect(sendEmail(db, { to: 'bad@example.com', subject: 'Hi', html: '<p>Hi</p>' })).rejects.toThrow(
'HTTP email API error (422): Invalid recipient',
)
await expect(
gatewayFor(db).send(bareplatform(db), { to: 'bad@example.com', subject: 'Hi', html: '<p>Hi</p>' }),
).rejects.toThrow('HTTP email API error (422): Invalid recipient')
})
})
describe('sendEmail — Cloudflare provider', () => {
describe('send — Cloudflare provider', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
@@ -253,13 +269,12 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined),
} satisfies Platform
await sendEmail(platform, { to: 'user@example.com', subject: 'Hello', html: '<p>Hi there</p>' })
await gatewayFor(db).send(platformWithBinding(db, { send: sendMock }), {
to: 'user@example.com',
subject: 'Hello',
html: '<p>Hi there</p>',
})
expect(sendMock).toHaveBeenCalledWith({
to: 'user@example.com',
@@ -276,13 +291,8 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_enabled', value: 'true' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: vi.fn() } as T) : undefined),
} satisfies Platform
await expect(isEmailConfigured(platform)).resolves.toBe(false)
await expect(gatewayFor(db).isConfigured(platformWithBinding(db, { send: vi.fn() }))).resolves.toBe(false)
})
it('reports false when config exists but email is disabled', async () => {
@@ -295,10 +305,10 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_http_api_key', value: 'my-api-key' },
])
await expect(isEmailConfigured(db)).resolves.toBe(false)
await expect(sendEmail(db, { to: 'user@example.com', subject: 'Hi', html: '<p>Hi</p>' })).rejects.toThrow(
'Email is disabled',
)
await expect(gatewayFor(db).isConfigured(bareplatform(db))).resolves.toBe(false)
await expect(
gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hi', html: '<p>Hi</p>' }),
).rejects.toThrow('Email is disabled')
})
it('returns false when email is enabled but provider is missing', async () => {
@@ -308,7 +318,7 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(isEmailConfigured(db)).resolves.toBe(false)
await expect(gatewayFor(db).isConfigured(bareplatform(db))).resolves.toBe(false)
})
it('returns enabled settings with null config when sender is missing', async () => {
@@ -318,13 +328,13 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_provider', value: 'smtp' },
])
await expect(getEmailSettings(db)).resolves.toEqual({
await expect(gatewayFor(db).getSettings(bareplatform(db))).resolves.toEqual({
enabled: true,
config: null,
})
})
it('rethrows non-configuration errors from getEmailSettings', async () => {
it('rethrows non-configuration errors from getSettings', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
@@ -332,10 +342,10 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(getEmailSettings(db)).rejects.toThrow('SMTP host and port are required')
await expect(gatewayFor(db).getSettings(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
})
it('rethrows non-configuration errors from isEmailConfigured', async () => {
it('rethrows non-configuration errors from isConfigured', async () => {
const { db } = await createTestApp()
await db.insert(schema.systemOptions).values([
{ key: 'email_enabled', value: 'true' },
@@ -343,7 +353,7 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_from', value: 'no-reply@example.com' },
])
await expect(isEmailConfigured(db)).rejects.toThrow('SMTP host and port are required')
await expect(gatewayFor(db).isConfigured(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
})
it('prefers explicit text for Cloudflare send()', async () => {
@@ -354,13 +364,8 @@ describe('sendEmail — Cloudflare provider', () => {
{ key: 'email_provider', value: 'cloudflare' },
{ key: 'email_from', value: 'no-reply@zpan.space' },
])
const platform = {
db,
getEnv: () => undefined,
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined),
} satisfies Platform
await sendEmail(platform, {
await gatewayFor(db).send(platformWithBinding(db, { send: sendMock }), {
to: 'user@example.com',
subject: 'Hello',
html: '<p>Hi there</p>',
+179
View File
@@ -0,0 +1,179 @@
import type { Platform } from '../../platform/interface'
import type {
EmailConfig,
EmailGateway,
EmailMessage,
EmailSettings,
HttpConfig,
SmtpConfig,
SystemOptionsRepo,
} from '../../usecases/ports'
interface CloudflareEmailBinding {
send(message: {
to: string | string[]
from: string | { email: string; name: string }
subject: string
html?: string
text?: string
}): Promise<{ messageId: string }>
}
const CLOUDFLARE_EMAIL_BINDING = 'EMAIL'
function getCloudflareBinding(platform: Platform): CloudflareEmailBinding | undefined {
return platform.getBinding<CloudflareEmailBinding>(CLOUDFLARE_EMAIL_BINDING)
}
function isConfigError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes('Email provider not configured') || error.message.includes('Email sender not configured'))
)
}
async function sendViaSmtp(from: string, smtp: SmtpConfig, message: EmailMessage): Promise<void> {
// Dynamic import: nodemailer uses Node.js net/tls modules unavailable on CF Workers.
// This ensures the module is only loaded when SMTP is actually used (Node.js target).
const { createTransport } = await import('nodemailer')
const transporter = createTransport({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
})
await transporter.sendMail({
from,
to: message.to,
subject: message.subject,
html: message.html,
})
}
async function sendViaHttp(from: string, http: HttpConfig, message: EmailMessage): Promise<void> {
const res = await fetch(http.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${http.apiKey}`,
},
body: JSON.stringify({
from,
to: message.to,
subject: message.subject,
html: message.html,
}),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`HTTP email API error (${res.status}): ${body}`)
}
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
async function sendViaCloudflare(platform: Platform, from: string, message: EmailMessage): Promise<void> {
const binding = getCloudflareBinding(platform)
if (!binding) throw new Error(`Cloudflare email binding "${CLOUDFLARE_EMAIL_BINDING}" is not configured`)
await binding.send({
to: message.to,
from,
subject: message.subject,
html: message.html,
text: message.text ?? stripHtml(message.html),
})
}
export function createEmailGateway(systemOptions: SystemOptionsRepo): EmailGateway {
async function loadOptions(): Promise<Map<string, string>> {
const rows = await systemOptions.listByKeyLike('email_%')
return new Map(rows.map((r) => [r.key, r.value]))
}
async function getConfig(platform: Platform): Promise<EmailConfig> {
const opts = await loadOptions()
const provider = opts.get('email_provider')
const from = opts.get('email_from')
if (!provider) {
throw new Error('Email provider not configured: set email_provider in system options')
}
if (provider !== 'smtp' && provider !== 'http' && provider !== 'cloudflare') {
throw new Error(`Unknown email provider: ${provider}`)
}
if (!from) throw new Error('Email sender not configured: set email_from in system options')
if (provider === 'smtp') {
const host = opts.get('email_smtp_host')
const port = opts.get('email_smtp_port')
if (!host || !port) throw new Error('SMTP host and port are required')
return {
provider,
from,
smtp: {
host,
port: Number(port),
user: opts.get('email_smtp_user') ?? '',
pass: opts.get('email_smtp_pass') ?? '',
secure: opts.get('email_smtp_secure') === 'true',
},
}
}
if (provider === 'cloudflare') {
if (!getCloudflareBinding(platform)) {
throw new Error(`Cloudflare email binding "${CLOUDFLARE_EMAIL_BINDING}" is not configured`)
}
return { provider, from }
}
const url = opts.get('email_http_url')
const apiKey = opts.get('email_http_api_key')
if (!url || !apiKey) throw new Error('HTTP email url and api_key are required')
return { provider, from, http: { url, apiKey } }
}
async function isEnabled(): Promise<boolean> {
const opts = await loadOptions()
return opts.get('email_enabled') === 'true'
}
async function isConfigured(platform: Platform): Promise<boolean> {
if (!(await isEnabled())) return false
try {
await getConfig(platform)
return true
} catch (error) {
if (isConfigError(error)) return false
throw error
}
}
return {
getConfig,
isConfigured,
async getSettings(platform: Platform): Promise<EmailSettings> {
const enabled = await isEnabled()
try {
const config = await getConfig(platform)
return { enabled, config }
} catch (error) {
if (isConfigError(error)) return { enabled, config: null }
throw error
}
},
async send(platform: Platform, message: EmailMessage): Promise<void> {
if (!(await isConfigured(platform))) throw new Error('Email is disabled')
const config = await getConfig(platform)
if (config.provider === 'smtp') return sendViaSmtp(config.from, config.smtp, message)
if (config.provider === 'http') return sendViaHttp(config.from, config.http, message)
return sendViaCloudflare(platform, config.from, message)
},
}
}
@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from 'vitest'
import type { Platform } from '../../platform/interface'
import type { S3Gateway, StorageRecord, StorageRepo } from '../../usecases/ports'
import { createImageUploadGateway, isImageMime } from './image-upload'
// biome-ignore lint/suspicious/noExplicitAny: test stub intentionally opaque
type Any = any
function mockR2Bucket() {
return {
put: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
}
}
function mockPlatform(opts: { r2?: ReturnType<typeof mockR2Bucket>; publicUrl?: string } = {}): Platform {
return {
db: {} as Any,
getEnv: (key) => (key === 'PUBLIC_IMAGES_URL' ? opts.publicUrl : undefined),
getBinding: <T>(key: string) => (key === 'PUBLIC_IMAGES' && opts.r2 ? (opts.r2 as unknown as T) : undefined),
}
}
// A StorageRepo whose `select('public')` either returns a storage or throws
// (no public storage configured). Only `select` is exercised by the gateway.
function mockStorages(opts: { storage?: StorageRecord; selectThrows?: boolean } = {}): StorageRepo {
return {
select: async () => {
if (opts.selectThrows) throw new Error('no storage')
return opts.storage as StorageRecord
},
} as unknown as StorageRepo
}
function mockS3() {
return {
putObject: vi.fn().mockResolvedValue(16),
getPublicUrl: vi.fn().mockReturnValue('https://s3.example/bucket/key'),
deleteObject: vi.fn().mockResolvedValue(undefined),
} as unknown as S3Gateway & {
putObject: ReturnType<typeof vi.fn>
getPublicUrl: ReturnType<typeof vi.fn>
deleteObject: ReturnType<typeof vi.fn>
}
}
function makeFile(type: string, bytes = 16): File {
return new File([new Uint8Array(bytes)], `f.${type.split('/')[1]}`, { type })
}
describe('isImageMime', () => {
it('accepts png/jpeg/webp', () => {
expect(isImageMime('image/png')).toBe(true)
expect(isImageMime('image/jpeg')).toBe(true)
expect(isImageMime('image/webp')).toBe(true)
})
it('rejects other mimes', () => {
expect(isImageMime('image/gif')).toBe(false)
expect(isImageMime('application/pdf')).toBe(false)
expect(isImageMime('')).toBe(false)
expect(isImageMime(undefined)).toBe(false)
expect(isImageMime(42)).toBe(false)
})
})
describe('uploadPublicImage — R2 binding path', () => {
it('uses R2 binding when PUBLIC_IMAGES + PUBLIC_IMAGES_URL both set', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toBe('https://pub-abc.r2.dev/_system/avatars/u1.png')
expect(r2.put).toHaveBeenCalledOnce()
expect(r2.put.mock.calls[0]?.[0]).toBe('_system/avatars/u1.png')
expect(r2.put.mock.calls[0]?.[2]).toEqual({ httpMetadata: { contentType: 'image/png' } })
})
it('maps mime to correct file extension (jpeg → jpg)', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/jpeg'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toMatch(/\.jpg$/)
})
it('trims a trailing slash in PUBLIC_IMAGES_URL', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev/' })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toBe('https://pub-abc.r2.dev/_system/avatars/u1.png')
})
it('rejects invalid mime (gif) before touching R2', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/gif'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(400)
expect(r2.put).not.toHaveBeenCalled()
})
it('rejects file > 2 MiB before touching R2', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png', 3 * 1024 * 1024))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(413)
expect(r2.put).not.toHaveBeenCalled()
})
it('falls back to S3 path when binding is missing (PUBLIC_IMAGES_URL alone ignored)', async () => {
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ publicUrl: 'https://pub-abc.r2.dev' })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(503)
})
it('falls back to S3 path when PUBLIC_IMAGES_URL is missing (binding alone ignored)', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2 })
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(503)
expect(r2.put).not.toHaveBeenCalled()
})
it('returns 503 when neither binding nor public S3 storage is available', async () => {
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform()
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(503)
})
it('uploads via S3 gateway when a public storage is configured', async () => {
const s3 = mockS3()
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
const platform = mockPlatform()
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toBe('https://s3.example/bucket/key')
expect(s3.putObject).toHaveBeenCalledOnce()
expect(s3.putObject.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
expect(s3.getPublicUrl.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
})
})
describe('deletePublicImageVariants — R2 binding path', () => {
it('deletes all 3 mime variants via R2 binding', async () => {
const r2 = mockR2Bucket()
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
await gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')
expect(r2.delete).toHaveBeenCalledTimes(3)
const keys = r2.delete.mock.calls.map((c) => c[0] as string)
expect(keys).toContain('_system/avatars/u1.png')
expect(keys).toContain('_system/avatars/u1.jpg')
expect(keys).toContain('_system/avatars/u1.webp')
})
it('deletes all 3 mime variants via S3 gateway when a public storage is configured', async () => {
const s3 = mockS3()
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
const platform = mockPlatform()
await gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')
expect(s3.deleteObject).toHaveBeenCalledTimes(3)
const keys = s3.deleteObject.mock.calls.map((c) => c[1] as string)
expect(keys).toContain('_system/avatars/u1.png')
expect(keys).toContain('_system/avatars/u1.jpg')
expect(keys).toContain('_system/avatars/u1.webp')
})
it('is a no-op when no backend is configured', async () => {
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
const platform = mockPlatform()
await expect(gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')).resolves.toBeUndefined()
})
})
+103
View File
@@ -0,0 +1,103 @@
import { mimeToExt } from '../../lib/mime-utils'
import type { Platform } from '../../platform/interface'
import type {
ImageMime,
ImageUpload,
ImageUploadResult,
S3Gateway,
StorageRecord,
StorageRepo,
} from '../../usecases/ports'
import { IMAGE_MIMES, MAX_IMAGE_SIZE } from '../../usecases/ports'
export function isImageMime(v: unknown): v is ImageMime {
return typeof v === 'string' && (IMAGE_MIMES as readonly string[]).includes(v)
}
export function imageKey(prefix: string, id: string, mime: ImageMime): string {
return `${prefix}/${id}.${mimeToExt(mime)}`
}
// Minimal R2Bucket interface we actually call — typed locally so we don't
// depend on @cloudflare/workers-types on non-CF builds.
interface R2BucketLike {
put(
key: string,
value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob,
options?: { httpMetadata?: { contentType?: string } },
): Promise<unknown>
delete(key: string): Promise<void>
}
type Backend =
| { kind: 'r2'; bucket: R2BucketLike; publicUrlBase: string }
| { kind: 's3'; storage: StorageRecord }
| { kind: 'none' }
export function createImageUploadGateway(s3: S3Gateway, storages: StorageRepo): ImageUpload {
// CF deployment with `PUBLIC_IMAGES` binding + `PUBLIC_IMAGES_URL` env var →
// writes via R2 binding (zero-auth, zero-egress), reads via R2's public
// domain (direct browser fetch, no Worker round-trip per image).
// Everything else → falls back to the user-configured `mode='public'` S3
// storage in the `storages` table.
async function getBackend(platform: Platform): Promise<Backend> {
const r2 = platform.getBinding<R2BucketLike>('PUBLIC_IMAGES')
const publicUrl = platform.getEnv('PUBLIC_IMAGES_URL')
if (r2 && publicUrl) {
return { kind: 'r2', bucket: r2, publicUrlBase: publicUrl.replace(/\/$/, '') }
}
try {
const storage = await storages.select('public')
return { kind: 's3', storage }
} catch {
return { kind: 'none' }
}
}
return {
async uploadPublicImage(platform, prefix, id, file): Promise<ImageUploadResult> {
if (!isImageMime(file.type)) {
return { ok: false, status: 400, error: 'Only PNG, JPG, and WebP images are allowed' }
}
if (file.size > MAX_IMAGE_SIZE) {
return { ok: false, status: 413, error: 'File too large. Max 2 MiB.' }
}
const backend = await getBackend(platform)
if (backend.kind === 'none') {
return { ok: false, status: 503, error: 'No public storage configured' }
}
const key = imageKey(prefix, id, file.type)
const bytes = new Uint8Array(await file.arrayBuffer())
if (backend.kind === 'r2') {
await backend.bucket.put(key, bytes, { httpMetadata: { contentType: file.type } })
return { ok: true, url: `${backend.publicUrlBase}/${key}` }
}
await s3.putObject(backend.storage, key, bytes, file.type)
return { ok: true, url: s3.getPublicUrl(backend.storage, key) }
},
// Best-effort delete of every mime variant of a public image. DB clearing is
// the caller's responsibility — this only touches the backend object store.
async deletePublicImageVariants(platform, prefix, id): Promise<void> {
const backend = await getBackend(platform)
if (backend.kind === 'none') return
if (backend.kind === 'r2') {
await Promise.allSettled(IMAGE_MIMES.map((mime) => backend.bucket.delete(imageKey(prefix, id, mime))))
return
}
try {
await Promise.allSettled(
IMAGE_MIMES.map((mime) => s3.deleteObject(backend.storage, imageKey(prefix, id, mime))),
)
} catch (err) {
console.warn('[image-upload] S3 cleanup skipped:', err)
}
},
}
}
@@ -1,8 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CloudInvalidResponseError, CloudNetworkError, CloudUnboundError } from '../../usecases/ports'
import {
CloudInvalidResponseError,
CloudNetworkError,
CloudUnboundError,
createBoundCloudClient,
createPairing,
pollPairing,
@@ -1,81 +1,19 @@
import type { z } from 'zod'
import { type CloudClient, createCloudClient } from 'zpan-cloud-sdk'
import {
type CloudInstanceInfo,
CloudInvalidResponseError,
CloudNetworkError,
CloudUnboundError,
type EntitlementRefreshResponse,
type LicensingCloudGateway,
type PairingPollResponse,
type PairingResponse,
} from '../../usecases/ports'
const CLOUD_REQUEST_TIMEOUT_MS = 10_000
const JSON_HEADERS = { 'content-type': 'application/json' }
export interface PairingResponse {
code: string
pairingUrl: string
expiresAt: string
}
export interface PairingPollResponse {
status: 'pending' | 'approved' | 'denied' | 'expired'
refreshToken?: string
certificate?: string
binding?: LicenseBindingInfo
account?: LicenseAccountInfo
}
export interface EntitlementRefreshResponse {
refreshToken: string
certificate: string
binding: LicenseBindingInfo
account: LicenseAccountInfo
nextRefreshAfter?: string
}
export interface LicenseBindingInfo {
id: string
instanceId: string
storeId: string
authorizedHosts: string[]
}
export interface LicenseAccountInfo {
id: string
email?: string | null
}
// The payload ZPan Cloud expects for pairing/refresh. Its `runtime` shape is
// fixed by zpan-cloud-sdk and is intentionally decoupled from the richer
// InstanceInfo the About page consumes (which has flat runtime + platform).
export interface CloudInstanceInfo {
id: string
name: string
url: string
version: string
commit?: string | null
runtime?: {
provider: 'cloudflare' | 'node'
target: 'cloudflare-worker' | 'node/docker'
} | null
server?: { os?: { platform?: string | null; arch?: string | null; release?: string | null } | null } | null
node?: { version?: string | null } | null
}
export class CloudInvalidResponseError extends Error {
constructor() {
super('Cloud response missing certificate')
this.name = 'CloudInvalidResponseError'
}
}
export class CloudUnboundError extends Error {
constructor() {
super('Instance unbound from cloud')
this.name = 'CloudUnboundError'
}
}
export class CloudNetworkError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : 'Cloud network error')
this.name = 'CloudNetworkError'
}
}
function cloudApiBaseUrl(baseUrl: string): string {
return `${baseUrl.replace(/\/$/, '')}/api`
}
@@ -227,3 +165,15 @@ function cloudErrorCode(data: unknown) {
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') return error.code
return null
}
export function createLicensingCloudGateway(): LicensingCloudGateway {
return {
createPairing,
pollPairing,
refreshEntitlement,
unbindCloudLicense,
confirmCloudLicense,
createBoundCloudClient,
requestCloudJson,
}
}
@@ -1,5 +1,5 @@
import type { Storage } from '@shared/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Storage } from '../../shared/types'
import { S3Service } from './s3.js'
const mockSend = vi.fn()
@@ -11,27 +11,14 @@ import {
UploadPartCommand,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { attachmentContentDisposition } from '../../shared/content-disposition'
/**
* The subset of a storage row the S3 client needs. A DB storage row
* (`typeof storages.$inferSelect`) structurally satisfies this, so callers pass
* rows straight from `getStorage` without casting.
*/
export interface S3StorageCredentials {
bucket: string
endpoint: string
region: string
accessKey: string
secretKey: string
customHost: string | null
}
import { attachmentContentDisposition } from '@shared/content-disposition'
import type { S3Gateway, S3StorageCredentials } from '../../usecases/ports'
const DEFAULT_EXPIRES_IN = 3600
const MULTIPART_PART_SIZE = 5 * 1024 * 1024
const SMALL_STREAM_PUT_BUFFER_SIZE = 256 * 1024
export class S3Service {
export class S3Service implements S3Gateway {
createClient(storage: S3StorageCredentials): S3Client {
return new S3Client({
region: storage.region,
@@ -501,3 +488,5 @@ function xmlUnescape(value: string): string {
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&')
}
export type { S3StorageCredentials } from '../../usecases/ports'
@@ -1,19 +1,17 @@
import { Unzip, UnzipInflate, unzipSync } from 'fflate'
import { Unzip, UnzipInflate, unzipSync, Zip, ZipDeflate, ZipPassThrough, type Zippable, zipSync } from 'fflate'
import type {
CompressionSourceDirectory,
StreamingZipExtraction,
StreamingZipFile,
ValidatedZip,
ZipDirectoryPlan,
ZipGateway,
ZipSourceObject,
ZipSourceStream,
} from '../../usecases/ports'
import { ZIP_EXTRACT_LIMITS } from '../../usecases/ports'
export const ZIP_EXTRACT_LIMITS = {
totalOutputBytes: 1024 * 1024 * 1024,
singleFileBytes: 1024 * 1024 * 1024,
fileCount: 1000,
directoryDepth: 10,
} as const
export interface ExtractedZipEntry {
path: string
name: string
parentPath: string
bytes: Uint8Array
size: number
}
const textDecoder = new TextDecoder()
interface CentralDirectoryEntry {
name: string
@@ -24,34 +22,78 @@ interface CentralDirectoryEntry {
externalAttributes: number
}
export interface ZipDirectoryPlan {
folders: string[]
totalBytes: number
fileCount: number
function createZipArchive(objects: ZipSourceObject[], directories: CompressionSourceDirectory[] = []): Uint8Array {
const zippable: Zippable = {}
for (const directory of directories) zippable[`${directory.archivePath}/`] = new Uint8Array()
for (const object of objects) zippable[object.archivePath] = object.bytes
return zipSync(zippable, { level: 6 })
}
export interface ValidatedZip {
files: ExtractedZipEntry[]
folders: string[]
totalBytes: number
function createZipArchiveStream(
sources: ZipSourceStream[],
directories: CompressionSourceDirectory[] = [],
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
const zip = new Zip()
zip.ondata = (error, chunk, final) => {
if (error) {
controller.error(error)
return
}
if (chunk) controller.enqueue(new Uint8Array(chunk))
if (final) controller.close()
}
void streamZipEntries(zip, sources, directories, async () => {}).catch((error) => {
zip.terminate()
controller.error(error)
})
},
})
}
export interface StreamingZipFile {
path: string
name: string
parentPath: string
stream: ReadableStream<Uint8Array>
size: Promise<number>
async function streamZipEntries(
zip: Zip,
sources: ZipSourceStream[],
directories: CompressionSourceDirectory[],
waitForWrites: () => Promise<void>,
): Promise<void> {
for (const directory of directories) {
const entry = new ZipPassThrough(`${directory.archivePath}/`)
zip.add(entry)
entry.push(new Uint8Array(), true)
await waitForWrites()
}
for (const source of sources) {
const entry = new ZipDeflate(source.archivePath, { level: 6 })
zip.add(entry)
await pushStreamToZipEntry(await source.openStream(), entry, waitForWrites)
}
zip.end()
}
export interface StreamingZipExtraction {
folders: string[]
totalBytes: number
async function pushStreamToZipEntry(
stream: ReadableStream<Uint8Array>,
entry: ZipDeflate,
waitForWrites: () => Promise<void>,
): Promise<void> {
const reader = stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) {
entry.push(new Uint8Array(), true)
await waitForWrites()
return
}
entry.push(value, false)
await waitForWrites()
}
}
const textDecoder = new TextDecoder()
export function validateAndExtractZip(data: Uint8Array): ValidatedZip {
function validateAndExtractZip(data: Uint8Array): ValidatedZip {
const entries = readCentralDirectory(data)
validateEntries(entries)
@@ -77,7 +119,7 @@ export function validateAndExtractZip(data: Uint8Array): ValidatedZip {
return { files, folders, totalBytes }
}
export async function validateZipDirectory(
async function validateZipDirectory(
size: number,
readRange: (start: number, end: number) => Promise<Uint8Array>,
): Promise<ZipDirectoryPlan> {
@@ -103,7 +145,7 @@ export async function validateZipDirectory(
}
}
export async function streamValidatedZip(
async function streamValidatedZip(
data: ReadableStream<Uint8Array>,
onFile: (file: StreamingZipFile) => Promise<void>,
): Promise<StreamingZipExtraction> {
@@ -335,3 +377,13 @@ function uint16(data: Uint8Array, offset: number): number {
function uint32(data: Uint8Array, offset: number): number {
return (data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)) >>> 0
}
export function createZipGateway(): ZipGateway {
return {
createZipArchive,
createZipArchiveStream,
validateAndExtractZip,
validateZipDirectory,
streamValidatedZip,
}
}
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CfConflictError, CfCustomHostnamesClient, createCfClient } from './cf-custom-hostnames.js'
import { CfConflictError } from '../../usecases/ports'
import { CfCustomHostnamesClient, createCfClient } from './cf-custom-hostnames.js'
const TEST_CONFIG = {
apiToken: 'test-token',
@@ -1,19 +1,17 @@
import type { CfHostnameStatus, CfHostnamesProvider } from '../../usecases/ports'
import { CfConflictError } from '../../usecases/ports'
interface CfConfig {
apiToken: string
zoneId: string
cnameTarget: string
}
interface CfHostnameStatus {
status: 'pending' | 'active' | 'moved' | 'deleted' | 'blocked'
ssl_status: string
}
// CfCustomHostnamesClient is a thin wrapper around the Cloudflare Custom
// Hostnames API (CF for SaaS). When env vars are absent (Node self-hosted),
// register/delete are no-ops and getStatus always returns 'pending' so
// domains never auto-verify without crashing the server.
export class CfCustomHostnamesClient {
export class CfCustomHostnamesClient implements CfHostnamesProvider {
private readonly cfg: CfConfig | null
constructor(cfg: CfConfig | null) {
@@ -79,8 +77,6 @@ export class CfCustomHostnamesClient {
}
}
export class CfConflictError extends Error {}
export function createCfClient(getEnv: (key: string) => string | undefined): CfCustomHostnamesClient {
const apiToken = getEnv('CF_API_TOKEN')
const zoneId = getEnv('CF_ZONE_ID')
@@ -1,12 +1,5 @@
import { ZPAN_CHANGELOG_RAW_URL, ZPAN_RELEASES_LATEST_API_URL } from '../../shared/constants'
export interface ChangelogSource {
// Newest published release version (without the leading "v"), or null when the
// GitHub API is unreachable/rate-limited.
latestVersion: string | null
// Raw, product-facing CHANGELOG.md markdown for the drawer.
markdown: string
}
import { ZPAN_CHANGELOG_RAW_URL, ZPAN_RELEASES_LATEST_API_URL } from '@shared/constants'
import type { ChangelogProvider, ChangelogSource } from '../../usecases/ports'
async function fetchLatestReleaseVersion(): Promise<string | null> {
// Best-effort: the unauthenticated GitHub API is rate-limited (60/hr/IP), so a
@@ -51,3 +44,7 @@ export async function fetchChangelog(
cache = { at: now, value }
return value
}
export function createChangelogProvider(): ChangelogProvider {
return { fetchChangelog }
}
+126
View File
@@ -0,0 +1,126 @@
import { and, count, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { organization, user } from '../../db/auth-schema'
import { activityEvents } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { ActivityRepo } from '../../usecases/ports'
export function createActivityRepo(db: Database): ActivityRepo {
return {
async record(event) {
await db.insert(activityEvents).values({
id: nanoid(),
orgId: event.orgId,
userId: event.userId,
action: event.action,
targetType: event.targetType,
targetId: event.targetId ?? null,
targetName: event.targetName,
metadata: event.metadata ? JSON.stringify(event.metadata) : null,
createdAt: new Date(),
})
},
async list(orgId, opts) {
const page = opts.page ?? 1
const pageSize = opts.pageSize ?? 20
const offset = (page - 1) * pageSize
const countRows = await db.select({ count: count() }).from(activityEvents).where(eq(activityEvents.orgId, orgId))
const total = countRows[0]?.count ?? 0
const rows = await db
.select({
id: activityEvents.id,
orgId: activityEvents.orgId,
userId: activityEvents.userId,
action: activityEvents.action,
targetType: activityEvents.targetType,
targetId: activityEvents.targetId,
targetName: activityEvents.targetName,
metadata: activityEvents.metadata,
createdAt: activityEvents.createdAt,
userName: user.name,
userImage: user.image,
})
.from(activityEvents)
.leftJoin(user, eq(activityEvents.userId, user.id))
.where(eq(activityEvents.orgId, orgId))
.orderBy(desc(activityEvents.createdAt))
.limit(pageSize)
.offset(offset)
const items = rows.map((row) => ({
id: row.id,
orgId: row.orgId,
userId: row.userId,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null },
}))
return { items, total }
},
async listAdminAudit(opts) {
const page = opts.page ?? 1
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20))
const offset = (page - 1) * pageSize
const filters = [
opts.orgId ? eq(activityEvents.orgId, opts.orgId) : undefined,
opts.userId ? eq(activityEvents.userId, opts.userId) : undefined,
opts.action ? eq(activityEvents.action, opts.action) : undefined,
opts.targetType ? eq(activityEvents.targetType, opts.targetType) : undefined,
].filter(Boolean) as Parameters<typeof and>
const whereClause = filters.length > 0 ? and(...filters) : undefined
const countRows = await db.select({ count: count() }).from(activityEvents).where(whereClause)
const total = countRows[0]?.count ?? 0
const rows = await db
.select({
id: activityEvents.id,
orgId: activityEvents.orgId,
userId: activityEvents.userId,
action: activityEvents.action,
targetType: activityEvents.targetType,
targetId: activityEvents.targetId,
targetName: activityEvents.targetName,
metadata: activityEvents.metadata,
createdAt: activityEvents.createdAt,
userName: user.name,
userImage: user.image,
orgName: organization.name,
})
.from(activityEvents)
.leftJoin(user, eq(activityEvents.userId, user.id))
.leftJoin(organization, eq(activityEvents.orgId, organization.id))
.where(whereClause)
.orderBy(desc(activityEvents.createdAt))
.limit(pageSize)
.offset(offset)
const items = rows.map((row) => ({
id: row.id,
orgId: row.orgId,
userId: row.userId,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null },
orgName: row.orgName ?? null,
}))
return { items, total, page, pageSize }
},
}
}
+113
View File
@@ -0,0 +1,113 @@
import type { AnnouncementInput } from '@shared/schemas'
import { count, desc, eq, ne } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { announcements } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { AnnouncementRecord, AnnouncementRepo } from '../../usecases/ports'
type AnnouncementRow = typeof announcements.$inferSelect
function toRecord(row: AnnouncementRow): AnnouncementRecord {
return row as AnnouncementRecord
}
function pageParams(page: number, pageSize: number) {
return { limit: pageSize, offset: (page - 1) * pageSize }
}
function publishedAtFor(input: AnnouncementInput, existing?: AnnouncementRow): Date | null {
if (input.status === 'archived') return existing?.publishedAt ?? null
if (input.status !== 'published') return null
return existing?.publishedAt ?? new Date()
}
export function createAnnouncementRepo(db: Database): AnnouncementRepo {
async function getRow(id: string): Promise<AnnouncementRow | null> {
const rows = await db.select().from(announcements).where(eq(announcements.id, id)).limit(1)
return rows[0] ?? null
}
return {
async create(input, createdBy) {
const now = new Date()
const row: AnnouncementRow = {
id: nanoid(),
title: input.title,
body: input.body,
status: input.status,
priority: input.priority,
publishedAt: publishedAtFor(input),
expiresAt: null,
createdBy,
createdAt: now,
updatedAt: now,
}
await db.insert(announcements).values(row)
return toRecord(row)
},
async listAdmin(opts) {
const { limit, offset } = pageParams(opts.page, opts.pageSize)
const where = opts.status ? eq(announcements.status, opts.status) : undefined
const [items, totalRows] = await Promise.all([
db
.select()
.from(announcements)
.where(where)
.orderBy(desc(announcements.priority), desc(announcements.createdAt))
.limit(limit)
.offset(offset),
db.select({ count: count() }).from(announcements).where(where),
])
return { items: items.map(toRecord), total: totalRows[0]?.count ?? 0, page: opts.page, pageSize: opts.pageSize }
},
async get(id) {
const row = await getRow(id)
return row ? toRecord(row) : null
},
async update(id, input) {
const existing = await getRow(id)
if (!existing) return null
await db
.update(announcements)
.set({
title: input.title,
body: input.body,
status: input.status,
priority: input.priority,
publishedAt: publishedAtFor(input, existing),
updatedAt: new Date(),
})
.where(eq(announcements.id, id))
const updated = await getRow(id)
return updated ? toRecord(updated) : null
},
async delete(id) {
const existing = await getRow(id)
if (!existing) return false
await db.delete(announcements).where(eq(announcements.id, id))
return true
},
async listUser(opts) {
const { limit, offset } = pageParams(opts.page, opts.pageSize)
const baseCondition = opts.activeOnly ? eq(announcements.status, 'published') : ne(announcements.status, 'draft')
const [items, totalRows] = await Promise.all([
db
.select()
.from(announcements)
.where(baseCondition)
.orderBy(desc(announcements.priority), desc(announcements.publishedAt), desc(announcements.updatedAt))
.limit(limit)
.offset(offset),
db.select({ count: count() }).from(announcements).where(baseCondition),
])
return { items: items.map(toRecord), total: totalRows[0]?.count ?? 0, page: opts.page, pageSize: opts.pageSize }
},
}
}
@@ -1,7 +1,10 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { authedHeaders, createTestApp } from '../test/setup.js'
import { ApiKeyRateLimitError, verifyApiKey } from './api-keys'
import { authedHeaders, createTestApp } from '../../test/setup.js'
import { ApiKeyRateLimitError } from '../../usecases/ports'
import { createApiKeyGateway } from './api-keys'
const apiKeys = createApiKeyGateway()
type TestApp = Awaited<ReturnType<typeof createTestApp>>
@@ -91,9 +94,9 @@ describe('API key rate limits', () => {
const { orgId, userId } = await getUserAndOrg(db)
const apiKey = await createOrgApiKey(auth, 'remote-download', orgId, userId, 2)
expect(await verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
expect(await verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
await expect(verifyApiKey(auth, db, apiKey.key, 'remote-download')).rejects.toThrow(ApiKeyRateLimitError)
expect(await apiKeys.verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
expect(await apiKeys.verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
await expect(apiKeys.verifyApiKey(auth, db, apiKey.key, 'remote-download')).rejects.toThrow(ApiKeyRateLimitError)
expect(await getApiKeyRow(db, apiKey.id)).toMatchObject({ request_count: 2 })
})
+70
View File
@@ -0,0 +1,70 @@
import { defaultKeyHasher } from '@better-auth/api-key'
import { API_KEY_TEMPLATES, type ApiKeyPermissions, ApiKeyTemplate } from '@shared/api-key-templates'
import { eq } from 'drizzle-orm'
import { apikey } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import { type ApiKeyAuth, type ApiKeyGateway, ApiKeyRateLimitError, type VerifiedApiKey } from '../../usecases/ports'
type VerifyApiKeyResult = {
valid: boolean
error: { message: string; code: string; details?: { tryAgainIn?: number } } | null
key: VerifiedApiKey | null
}
export function createApiKeyGateway(): ApiKeyGateway {
return {
async verifyApiKey(auth, db, key, configId) {
const resolvedConfigId = await resolveApiKeyConfigId(db, key)
if (!resolvedConfigId) return null
if (configId && resolvedConfigId !== configId) return null
const result = await verify(auth, { configId: resolvedConfigId, key })
throwIfRateLimited(result)
if (result?.valid && result.key) return result.key
return null
},
async verifyApiKeyForPermission(auth, db, key, resource, action, configId) {
const resolvedConfigId = await resolveApiKeyConfigId(db, key)
if (!resolvedConfigId) return null
if (configId && resolvedConfigId !== configId) return null
const result = await verify(auth, {
configId: resolvedConfigId,
key,
permissions: { [resource]: [action] },
})
throwIfRateLimited(result)
if (result?.valid && result.key) return result.key
return null
},
hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource, action) {
return permissions?.[resource]?.includes(action) ?? false
},
isOrgApiKey(configId) {
return configId !== ApiKeyTemplate.WEBDAV
},
}
}
async function verify(auth: ApiKeyAuth, body: Record<string, unknown>): Promise<VerifyApiKeyResult | null> {
try {
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
return (await (auth.api as any).verifyApiKey({ body })) as VerifyApiKeyResult
} catch {
return null
}
}
function throwIfRateLimited(result: VerifyApiKeyResult | null) {
if (result?.error?.code !== 'RATE_LIMITED') return
throw new ApiKeyRateLimitError(result.error.message, result.error.details?.tryAgainIn)
}
async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise<string | null> {
const hashedKey = await defaultKeyHasher(rawKey)
const rows = await db.select({ configId: apikey.configId }).from(apikey).where(eq(apikey.key, hashedKey)).limit(1)
const configId = rows[0]?.configId
if (!configId || !API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate)) return null
return configId
}
@@ -0,0 +1,29 @@
import { and, eq } from 'drizzle-orm'
import { DirType } from '../../../shared/constants'
import { matters } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { ArchiveTargetFolderRepo } from '../../usecases/ports'
async function requireTargetFolder(db: Database, orgId: string, targetFolder: string): Promise<void> {
if (targetFolder === '') return
const slash = targetFolder.lastIndexOf('/')
const parent = slash >= 0 ? targetFolder.slice(0, slash) : ''
const name = slash >= 0 ? targetFolder.slice(slash + 1) : targetFolder
const rows = await db
.select()
.from(matters)
.where(
and(eq(matters.orgId, orgId), eq(matters.parent, parent), eq(matters.name, name), eq(matters.status, 'active')),
)
.limit(1)
const target = rows[0]
if (!target) throw new Error('Target folder not found')
if (target.dirtype === DirType.FILE) throw new Error('Target folder must be a folder')
}
export function createArchiveTargetFolderRepo(db: Database): ArchiveTargetFolderRepo {
return {
requireTargetFolder: (orgId, targetFolder) => requireTargetFolder(db, orgId, targetFolder),
}
}
@@ -1,18 +1,11 @@
import { describe, expect, it } from 'vitest'
import { createTestApp } from '../test/setup.js'
import {
cancelBackgroundJob,
createBackgroundJob,
getBackgroundJob,
listBackgroundJobs,
retryBackgroundJob,
updateBackgroundJob,
} from './background-jobs'
import { createTestApp } from '../../test/setup.js'
import { createBackgroundJobRepo } from './background-job'
describe('background job service', () => {
it('creates, lists, and reads jobs with generic metadata and progress', async () => {
const { db } = await createTestApp()
const job = await createBackgroundJob(db, {
const job = await createBackgroundJobRepo(db).create({
orgId: 'org-service',
userId: 'user-service',
type: 'remote_download',
@@ -22,8 +15,12 @@ describe('background job service', () => {
progress: { inputBytes: 1024, currentFilename: 'file.zip' },
})
const listed = await listBackgroundJobs(db, 'org-service', { type: 'remote_download', page: 1, pageSize: 10 })
const loaded = await getBackgroundJob(db, 'org-service', job.id)
const listed = await createBackgroundJobRepo(db).list('org-service', {
type: 'remote_download',
page: 1,
pageSize: 10,
})
const loaded = await createBackgroundJobRepo(db).get('org-service', job.id)
expect(listed).toMatchObject({ total: 1, items: [{ id: job.id }] })
expect(loaded).toMatchObject({
@@ -38,20 +35,20 @@ describe('background job service', () => {
it('updates nullable fields and terminal timestamps explicitly', async () => {
const { db } = await createTestApp()
const job = await createBackgroundJob(db, {
const job = await createBackgroundJobRepo(db).create({
orgId: 'org-update',
userId: 'user-update',
type: 'archive_compress',
progress: { currentFilename: 'old.txt' },
})
await updateBackgroundJob(db, 'org-update', job.id, {
await createBackgroundJobRepo(db).update('org-update', job.id, {
status: 'failed',
errorMessage: 'first_failure',
progress: { currentFilename: null },
resultMetadata: { attempted: true },
})
const updated = await updateBackgroundJob(db, 'org-update', job.id, {
const updated = await createBackgroundJobRepo(db).update('org-update', job.id, {
errorMessage: null,
resultMetadata: null,
})
@@ -65,42 +62,49 @@ describe('background job service', () => {
it('cancels active cancelable jobs and rejects unsupported cancellation', async () => {
const { db } = await createTestApp()
const cancelable = await createBackgroundJob(db, {
const cancelable = await createBackgroundJobRepo(db).create({
orgId: 'org-cancel',
userId: 'user-cancel',
type: 'archive_extract',
})
const blocked = await createBackgroundJob(db, {
const blocked = await createBackgroundJobRepo(db).create({
orgId: 'org-cancel',
userId: 'user-cancel',
type: 'archive_extract',
cancelable: false,
})
await expect(cancelBackgroundJob(db, 'org-cancel', cancelable.id)).resolves.toMatchObject({ status: 'canceled' })
await expect(cancelBackgroundJob(db, 'org-cancel', blocked.id)).rejects.toMatchObject({ code: 'not_cancelable' })
await expect(createBackgroundJobRepo(db).cancel('org-cancel', cancelable.id)).resolves.toMatchObject({
status: 'canceled',
})
await expect(createBackgroundJobRepo(db).cancel('org-cancel', blocked.id)).rejects.toMatchObject({
code: 'not_cancelable',
})
})
it('creates retries only for failed retryable jobs', async () => {
const { db } = await createTestApp()
const retryable = await createBackgroundJob(db, {
const retryable = await createBackgroundJobRepo(db).create({
orgId: 'org-retry',
userId: 'user-retry',
type: 'archive_extract',
retryable: true,
})
const notRetryable = await createBackgroundJob(db, {
const notRetryable = await createBackgroundJobRepo(db).create({
orgId: 'org-retry',
userId: 'user-retry',
type: 'archive_extract',
})
await updateBackgroundJob(db, 'org-retry', retryable.id, { status: 'failed', errorMessage: 'bad_zip' })
await updateBackgroundJob(db, 'org-retry', notRetryable.id, { status: 'failed', errorMessage: 'bad_zip' })
await createBackgroundJobRepo(db).update('org-retry', retryable.id, { status: 'failed', errorMessage: 'bad_zip' })
await createBackgroundJobRepo(db).update('org-retry', notRetryable.id, {
status: 'failed',
errorMessage: 'bad_zip',
})
const retry = await retryBackgroundJob(db, 'org-retry', retryable.id)
const retry = await createBackgroundJobRepo(db).retry('org-retry', retryable.id)
expect(retry).toMatchObject({ status: 'queued', retriedFromJobId: retryable.id, errorMessage: null })
await expect(retryBackgroundJob(db, 'org-retry', notRetryable.id)).rejects.toMatchObject({
await expect(createBackgroundJobRepo(db).retry('org-retry', notRetryable.id)).rejects.toMatchObject({
code: 'not_retryable',
})
})
+210
View File
@@ -0,0 +1,210 @@
import type { BackgroundJob, BackgroundJobStatus } from '@shared/types'
import { and, count, desc, eq, type SQL } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { backgroundJobs } from '../../db/schema'
import type { Database } from '../../platform/interface'
import {
BackgroundJobError,
type BackgroundJobMetadata,
type BackgroundJobRepo,
type ListBackgroundJobsOptions,
} from '../../usecases/ports'
type BackgroundJobRow = typeof backgroundJobs.$inferSelect
const ACTIVE_STATUSES: BackgroundJobStatus[] = ['queued', 'running']
function backgroundJobWhere(orgId: string, opts: ListBackgroundJobsOptions): SQL | undefined {
const filters = [eq(backgroundJobs.orgId, orgId)]
if (opts.status) filters.push(eq(backgroundJobs.status, opts.status))
if (opts.type) filters.push(eq(backgroundJobs.type, opts.type))
return and(...filters)
}
function finishedAtFor(status: string, current: Date | null, now: Date): Date | null {
if (current) return current
return ['completed', 'failed', 'canceled'].includes(status) ? now : null
}
function stringifyMetadata(value: BackgroundJobMetadata | null | undefined): string | null {
return value == null ? null : JSON.stringify(value)
}
function parseMetadata(value: string | null): BackgroundJobMetadata | null {
return value == null ? null : (JSON.parse(value) as BackgroundJobMetadata)
}
function toIso(value: Date | null): string | null {
return value?.toISOString() ?? null
}
function toBackgroundJob(row: BackgroundJobRow): BackgroundJob {
return {
id: row.id,
orgId: row.orgId,
userId: row.userId,
type: row.type,
status: row.status as BackgroundJobStatus,
targetFolder: row.targetFolder,
targetPath: row.targetPath,
metadata: parseMetadata(row.metadata),
progress: {
inputBytes: row.inputBytes,
outputBytes: row.outputBytes,
processedBytes: row.processedBytes,
fileCount: row.fileCount,
currentFilename: row.currentFilename,
},
errorMessage: row.errorMessage,
resultMetadata: parseMetadata(row.resultMetadata),
retryable: row.retryable,
cancelable: row.cancelable,
retriedFromJobId: row.retriedFromJobId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
startedAt: toIso(row.startedAt),
finishedAt: toIso(row.finishedAt),
}
}
export function createBackgroundJobRepo(db: Database): BackgroundJobRepo {
async function getRow(orgId: string, id: string): Promise<BackgroundJobRow | null> {
const rows = await db
.select()
.from(backgroundJobs)
.where(and(eq(backgroundJobs.id, id), eq(backgroundJobs.orgId, orgId)))
.limit(1)
return rows[0] ?? null
}
const repo: BackgroundJobRepo = {
async create(input) {
const now = new Date()
const row: typeof backgroundJobs.$inferInsert = {
id: nanoid(),
orgId: input.orgId,
userId: input.userId,
type: input.type,
status: 'queued',
targetFolder: input.targetFolder ?? null,
targetPath: input.targetPath ?? null,
metadata: stringifyMetadata(input.metadata),
inputBytes: input.progress?.inputBytes ?? 0,
outputBytes: input.progress?.outputBytes ?? 0,
processedBytes: input.progress?.processedBytes ?? 0,
fileCount: input.progress?.fileCount ?? 0,
currentFilename: input.progress?.currentFilename ?? null,
errorMessage: null,
resultMetadata: null,
retryable: input.retryable ?? false,
cancelable: input.cancelable ?? true,
retriedFromJobId: null,
createdAt: now,
updatedAt: now,
startedAt: null,
finishedAt: null,
}
await db.insert(backgroundJobs).values(row)
return toBackgroundJob(row as BackgroundJobRow)
},
async list(orgId, opts) {
const offset = (opts.page - 1) * opts.pageSize
const where = backgroundJobWhere(orgId, opts)
const [rows, totalRows] = await Promise.all([
db
.select()
.from(backgroundJobs)
.where(where)
.orderBy(desc(backgroundJobs.createdAt))
.limit(opts.pageSize)
.offset(offset),
db.select({ count: count() }).from(backgroundJobs).where(where),
])
return { items: rows.map(toBackgroundJob), total: totalRows[0]?.count ?? 0 }
},
async get(orgId, id) {
const row = await getRow(orgId, id)
if (!row) throw new BackgroundJobError('not_found')
return toBackgroundJob(row)
},
async update(orgId, id, input) {
const row = await getRow(orgId, id)
if (!row) throw new BackgroundJobError('not_found')
const nextStatus = input.status ?? row.status
const now = new Date()
const values: Partial<typeof backgroundJobs.$inferInsert> = {
status: nextStatus,
inputBytes: input.progress?.inputBytes ?? row.inputBytes,
outputBytes: input.progress?.outputBytes ?? row.outputBytes,
processedBytes: input.progress?.processedBytes ?? row.processedBytes,
fileCount: input.progress?.fileCount ?? row.fileCount,
currentFilename:
input.progress?.currentFilename === undefined ? row.currentFilename : input.progress.currentFilename,
errorMessage: input.errorMessage === undefined ? row.errorMessage : input.errorMessage,
resultMetadata:
input.resultMetadata === undefined ? row.resultMetadata : stringifyMetadata(input.resultMetadata),
retryable: input.retryable ?? row.retryable,
cancelable: input.cancelable ?? row.cancelable,
startedAt: input.startedAt === undefined ? row.startedAt : input.startedAt,
finishedAt: input.finishedAt === undefined ? finishedAtFor(nextStatus, row.finishedAt, now) : input.finishedAt,
updatedAt: now,
}
await db.update(backgroundJobs).set(values).where(eq(backgroundJobs.id, id))
return repo.get(orgId, id)
},
async cancel(orgId, id) {
const row = await getRow(orgId, id)
if (!row) throw new BackgroundJobError('not_found')
if (!ACTIVE_STATUSES.includes(row.status as BackgroundJobStatus) || !row.cancelable) {
throw new BackgroundJobError('not_cancelable')
}
const now = new Date()
await db
.update(backgroundJobs)
.set({ status: 'canceled', updatedAt: now, finishedAt: now })
.where(eq(backgroundJobs.id, id))
return repo.get(orgId, id)
},
async retry(orgId, id) {
const row = await getRow(orgId, id)
if (!row) throw new BackgroundJobError('not_found')
if (row.status !== 'failed' || !row.retryable) throw new BackgroundJobError('not_retryable')
const now = new Date()
const retry: typeof backgroundJobs.$inferInsert = {
id: nanoid(),
orgId: row.orgId,
userId: row.userId,
type: row.type,
status: 'queued',
targetFolder: row.targetFolder,
targetPath: row.targetPath,
metadata: row.metadata,
inputBytes: row.inputBytes,
outputBytes: 0,
processedBytes: 0,
fileCount: row.fileCount,
currentFilename: null,
errorMessage: null,
resultMetadata: null,
retryable: row.retryable,
cancelable: row.cancelable,
retriedFromJobId: row.id,
createdAt: now,
updatedAt: now,
startedAt: null,
finishedAt: null,
}
await db.insert(backgroundJobs).values(retry)
return toBackgroundJob(retry as BackgroundJobRow)
},
}
return repo
}
@@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest'
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../db/schema'
import type { Database } from '../platform/interface'
import { processCloudOrderQuotaChange } from './cloud-store'
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../../db/schema'
import type { Database } from '../../platform/interface'
import { createCloudStoreRepo } from './cloud-store'
function processCloudOrderQuotaChange(
db: Database,
...args: Parameters<ReturnType<typeof createCloudStoreRepo>['processCloudOrderQuotaChange']>
) {
return createCloudStoreRepo(db).processCloudOrderQuotaChange(...args)
}
function createAsyncDb(
quotaRows: Array<{ id: string }> = [{ id: 'quota-1' }],
@@ -2,13 +2,14 @@ import type { CloudOrderQuotaChange } from '@shared/schemas'
import type { CloudStoreTarget } from '@shared/types'
import { and, eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { member, organization, user } from '../db/auth-schema'
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../db/schema'
import { loadActiveLicenseBinding } from '../licensing/license-state'
import type { Database } from '../platform/interface'
import { type AtomicQuery, executeRows, executeWriteTransaction } from './db-transaction'
import { member, organization, user } from '../../db/auth-schema'
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../../db/schema'
import { type AtomicQuery, executeRows, executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
import type { CloudStoreBinding, CloudStoreRepo } from '../../usecases/ports'
import { createLicenseBindingRepo } from './license-binding'
export async function getAccessibleTargets(db: Database, userId: string): Promise<CloudStoreTarget[]> {
async function getAccessibleTargets(db: Database, userId: string): Promise<CloudStoreTarget[]> {
const rows = await db
.select({ orgId: organization.id, name: organization.name, metadata: organization.metadata, role: member.role })
.from(member)
@@ -19,10 +20,8 @@ export async function getAccessibleTargets(db: Database, userId: string): Promis
return rows.map((r) => ({ orgId: r.orgId, name: r.name, type: parseOrgType(r.metadata), role: r.role }))
}
export async function getCloudStoreBinding(
db: Database,
): Promise<{ boundLicenseId: string; storeId: string; refreshToken: string; instanceId: string }> {
const binding = await loadActiveLicenseBinding(db)
async function getCloudStoreBinding(db: Database): Promise<CloudStoreBinding> {
const binding = await createLicenseBindingRepo(db).loadActiveLicenseBinding()
if (!binding?.refreshToken || !binding.cloudStoreId) throw new Error('quota_store_binding_missing')
return {
boundLicenseId: binding.cloudBindingId,
@@ -35,7 +34,7 @@ export async function getCloudStoreBinding(
// Cloud-side accounting label for an order. Team purchases are labeled with
// the team name (the org is the customer); personal purchases keep the
// purchaser's email. Personal orgs are identified by their `personal-` slug.
export async function getCustomerLabel(db: Database, userId: string, orgId: string): Promise<string | null> {
async function getCustomerLabel(db: Database, userId: string, orgId: string): Promise<string | null> {
const orgs = await db
.select({ name: organization.name, slug: organization.slug })
.from(organization)
@@ -48,7 +47,7 @@ export async function getCustomerLabel(db: Database, userId: string, orgId: stri
return rows[0]?.email ?? null
}
export async function processCloudOrderQuotaChange(
async function processCloudOrderQuotaChange(
db: Database,
event: CloudOrderQuotaChange,
rawPayload: string,
@@ -369,3 +368,13 @@ function isUniqueConflict(error: unknown): boolean {
const message = error.message.toLowerCase()
return message.includes('unique') || message.includes('constraint failed')
}
export function createCloudStoreRepo(db: Database): CloudStoreRepo {
return {
getAccessibleTargets: (userId) => getAccessibleTargets(db, userId),
getCloudStoreBinding: () => getCloudStoreBinding(db),
getCustomerLabel: (userId, orgId) => getCustomerLabel(db, userId, orgId),
processCloudOrderQuotaChange: (event, rawPayload, payloadHash) =>
processCloudOrderQuotaChange(db, event, rawPayload, payloadHash),
}
}
@@ -0,0 +1,74 @@
import { asc, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { cloudTrafficReports } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type {
CloudTrafficReportRecord,
CloudTrafficReportRepo,
CloudTrafficReportStatus,
InsertCloudTrafficReportInput,
} from '../../usecases/ports'
function toRecord(row: typeof cloudTrafficReports.$inferSelect): CloudTrafficReportRecord {
return {
id: row.id,
orgId: row.orgId,
period: row.period,
source: row.source,
sourceId: row.sourceId,
eventId: row.eventId,
bytes: row.bytes,
storageId: row.storageId,
unitBytes: row.unitBytes,
creditsPerUnit: row.creditsPerUnit,
status: row.status as CloudTrafficReportStatus,
error: row.error,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export function createCloudTrafficReportRepo(db: Database): CloudTrafficReportRepo {
return {
async findByEventId(eventId) {
const rows = await db.select().from(cloudTrafficReports).where(eq(cloudTrafficReports.eventId, eventId)).limit(1)
return rows[0] ? toRecord(rows[0]) : undefined
},
async insert(input: InsertCloudTrafficReportInput) {
await db.insert(cloudTrafficReports).values({
id: nanoid(),
orgId: input.orgId,
period: input.period,
source: input.source,
sourceId: input.sourceId,
eventId: input.eventId,
bytes: input.bytes,
storageId: input.storageId,
unitBytes: input.unitBytes,
creditsPerUnit: input.creditsPerUnit,
status: input.status,
error: null,
createdAt: input.now,
updatedAt: input.now,
})
},
async updateStatus(eventId, status, error, now) {
await db
.update(cloudTrafficReports)
.set({ status, error, updatedAt: now })
.where(eq(cloudTrafficReports.eventId, eventId))
},
async listPending(limit) {
const rows = await db
.select()
.from(cloudTrafficReports)
.where(inArray(cloudTrafficReports.status, ['pending', 'failed']))
.orderBy(asc(cloudTrafficReports.createdAt))
.limit(limit)
return rows.map(toRecord)
},
}
}
+242
View File
@@ -0,0 +1,242 @@
import { downloadTaskRuntimeSchema } from '@shared/schemas'
import type { DownloadTask, DownloadTaskRuntime } from '@shared/types'
import { and, asc, count, desc, eq, inArray, like, type SQL, sql } from 'drizzle-orm'
import { downloadTasks } from '../../db/schema'
import type { Database } from '../../platform/interface'
import {
type CreateDownloadTaskRecordInput,
DownloadError,
type DownloadTaskRecord,
type DownloadTaskRepo,
type ListDownloadTasksFilters,
type UpdateDownloadTaskFields,
} from '../../usecases/ports'
type DownloadTaskRow = typeof downloadTasks.$inferSelect
function parseStringArray(value: string): string[] {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
} catch {
return []
}
}
function parseTaskRuntime(value: string | null): DownloadTaskRuntime | null {
if (!value) return null
return downloadTaskRuntimeSchema.parse(JSON.parse(value))
}
function emptyTaskProgress(): DownloadTask['status']['progress'] {
return {
download: { bytes: 0, totalBytes: null, bytesPerSecond: 0 },
upload: { bytes: 0, totalBytes: null, bytesPerSecond: 0 },
}
}
function toRecord(row: DownloadTaskRow): DownloadTaskRecord {
return {
id: row.id,
orgId: row.orgId,
createdByUserId: row.createdByUserId,
sourceType: row.sourceType,
sourceUri: row.sourceUri,
displayName: row.displayName,
targetFolder: row.targetFolder,
category: row.category,
tags: row.tags,
assignedDownloaderId: row.assignedDownloaderId,
status: row.status,
attempt: row.attempt,
billingAuthorizedBytes: row.billingAuthorizedBytes,
billingChargedBytes: row.billingChargedBytes,
billingChargedCredits: row.billingChargedCredits,
billingStatus: row.billingStatus,
errorCode: row.errorCode,
errorMessage: row.errorMessage,
resultObjectId: row.resultObjectId,
runtime: row.runtime,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
assignedAt: row.assignedAt,
startedAt: row.startedAt,
finishedAt: row.finishedAt,
}
}
function toDownloadTask(row: DownloadTaskRow): DownloadTask {
const runtime = parseTaskRuntime(row.runtime)
return {
id: row.id,
orgId: row.orgId,
createdBy: row.createdByUserId,
spec: {
source: {
type: row.sourceType as DownloadTask['spec']['source']['type'],
uri: row.sourceUri,
},
destination: {
folder: row.targetFolder,
name: row.displayName,
},
labels: {
category: row.category,
tags: parseStringArray(row.tags),
},
},
status: {
state: row.status as DownloadTask['status']['state'],
attempt: row.attempt,
assignment: row.assignedDownloaderId
? { downloaderId: row.assignedDownloaderId, assignedAt: row.assignedAt?.toISOString() ?? null }
: null,
progress: runtime?.progress ?? emptyTaskProgress(),
billing: {
state: row.billingStatus as DownloadTask['status']['billing']['state'],
authorizedBytes: row.billingAuthorizedBytes,
chargedBytes: row.billingChargedBytes,
chargedCredits: row.billingChargedCredits,
},
output: row.resultObjectId ? { objectId: row.resultObjectId } : null,
runtime,
error: row.errorMessage ? { code: row.errorCode, message: row.errorMessage } : null,
startedAt: row.startedAt?.toISOString() ?? null,
finishedAt: row.finishedAt?.toISOString() ?? null,
updatedAt: row.updatedAt.toISOString(),
},
createdAt: row.createdAt.toISOString(),
}
}
function orderBy(sortBy: NonNullable<ListDownloadTasksFilters['sortBy']>, sortDir: 'asc' | 'desc'): SQL {
const direction = sortDir === 'asc' ? asc : desc
if (sortBy === 'source') return direction(downloadTasks.sourceUri)
if (sortBy === 'category') return direction(downloadTasks.category)
if (sortBy === 'tags') return direction(downloadTasks.tags)
if (sortBy === 'status') return direction(downloadTasks.status)
if (sortBy === 'progress') {
return direction(sql<number>`
case
when json_extract(${downloadTasks.runtime}, '$.progress.download.totalBytes') is null
or json_extract(${downloadTasks.runtime}, '$.progress.download.totalBytes') = 0 then 0
else (
json_extract(${downloadTasks.runtime}, '$.progress.download.bytes') * 1000000 /
json_extract(${downloadTasks.runtime}, '$.progress.download.totalBytes')
)
end
`)
}
if (sortBy === 'eta') {
return direction(sql<number>`coalesce(json_extract(${downloadTasks.runtime}, '$.etaSeconds'), 9223372036854775807)`)
}
return direction(downloadTasks.createdAt)
}
export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
async function findRow(id: string): Promise<DownloadTaskRow | null> {
const rows = await db.select().from(downloadTasks).where(eq(downloadTasks.id, id)).limit(1)
return rows[0] ?? null
}
return {
async insert(input: CreateDownloadTaskRecordInput) {
await db.insert(downloadTasks).values({
id: input.id,
orgId: input.orgId,
createdByUserId: input.createdByUserId,
sourceType: input.sourceType,
sourceUri: input.sourceUri,
displayName: input.displayName,
targetFolder: input.targetFolder,
category: input.category,
tags: JSON.stringify(input.tags),
assignedDownloaderId: input.assignedDownloaderId,
status: input.status,
createdAt: input.now,
updatedAt: input.now,
assignedAt: input.assignedAt,
})
},
async list(filters: ListDownloadTasksFilters) {
const offset = (filters.page - 1) * filters.pageSize
const conditions: SQL[] = []
if (filters.orgId) conditions.push(eq(downloadTasks.orgId, filters.orgId))
if (filters.downloaderId) conditions.push(eq(downloadTasks.assignedDownloaderId, filters.downloaderId))
if (filters.status) conditions.push(eq(downloadTasks.status, filters.status))
if (filters.category) conditions.push(eq(downloadTasks.category, filters.category))
if (filters.tag) conditions.push(like(downloadTasks.tags, `%${JSON.stringify(filters.tag)}%`))
const where = conditions.length ? and(...conditions) : undefined
const [rows, totalRows] = await Promise.all([
db
.select()
.from(downloadTasks)
.where(where)
.orderBy(orderBy(filters.sortBy ?? 'createdAt', filters.sortDir ?? 'desc'))
.limit(filters.pageSize)
.offset(offset),
db.select({ count: count() }).from(downloadTasks).where(where),
])
return { items: rows.map(toDownloadTask), total: totalRows[0]?.count ?? 0, rows: rows.map(toRecord) }
},
async get(orgId, id) {
const rows = await db
.select()
.from(downloadTasks)
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId)))
.limit(1)
if (!rows[0]) throw new DownloadError('not_found')
return toDownloadTask(rows[0])
},
async getRecord(orgId, id) {
const rows = await db
.select()
.from(downloadTasks)
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId)))
.limit(1)
if (!rows[0]) throw new DownloadError('not_found')
return toRecord(rows[0])
},
async findRecord(id) {
const row = await findRow(id)
return row ? toRecord(row) : null
},
async setFields(id, fields: UpdateDownloadTaskFields) {
await db.update(downloadTasks).set(fields).where(eq(downloadTasks.id, id))
},
async delete(id) {
await db.delete(downloadTasks).where(eq(downloadTasks.id, id))
},
async listQueued(limit) {
const rows = await db
.select()
.from(downloadTasks)
.where(eq(downloadTasks.status, 'queued'))
.orderBy(asc(downloadTasks.createdAt))
.limit(limit)
return rows.map(toRecord)
},
async requeueAssignedTo(downloaderId, statuses, now) {
await db
.update(downloadTasks)
.set({ status: 'queued', assignedDownloaderId: null, runtime: null, assignedAt: null, updatedAt: now })
.where(and(eq(downloadTasks.assignedDownloaderId, downloaderId), inArray(downloadTasks.status, statuses)))
},
async requeueAssignedToMany(downloaderIds, statuses, now) {
if (downloaderIds.length === 0) return
await db
.update(downloadTasks)
.set({ status: 'queued', assignedDownloaderId: null, assignedAt: null, runtime: null, updatedAt: now })
.where(and(inArray(downloadTasks.assignedDownloaderId, downloaderIds), inArray(downloadTasks.status, statuses)))
},
}
}
+151
View File
@@ -0,0 +1,151 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { downloaders, downloadTasks } from '../../db/schema'
import { constantTimeEqual } from '../../lib/constant-time'
import type { Platform } from '../../platform/interface'
import type { DownloadTokenClaims, DownloadTokenGateway, TaskUploadTokenClaims } from '../../usecases/ports'
import { DOWNLOAD_TOKEN_VERSION } from '../../usecases/ports'
const downloaderTokenSchema = z.object({
v: z.literal(DOWNLOAD_TOKEN_VERSION),
typ: z.literal('downloader'),
downloaderId: z.string().min(1),
jti: z.string().min(1),
iat: z.number().int(),
})
const taskUploadTokenSchema = z.object({
v: z.literal(DOWNLOAD_TOKEN_VERSION),
typ: z.literal('download-task-upload'),
taskId: z.string().min(1),
downloaderId: z.string().min(1),
orgId: z.string().min(1),
targetFolder: z.string(),
createdByUserId: z.string().min(1),
scopes: z.array(z.string().min(1)),
jti: z.string().min(1),
iat: z.number().int(),
exp: z.number().int(),
})
export function createDownloadTokenGateway(): DownloadTokenGateway {
async function verifyDownloadToken(platform: Platform, token: string): Promise<DownloadTokenClaims | null> {
const [payload, signature, extra] = token.split('.')
if (!payload || !signature || extra !== undefined) return null
const expected = await signPayload(platform, payload)
if (!constantTimeEqual(signature, expected)) return null
let raw: unknown
try {
raw = JSON.parse(base64UrlDecode(payload))
} catch {
return null
}
const downloader = downloaderTokenSchema.safeParse(raw)
if (downloader.success) return downloader.data
const task = taskUploadTokenSchema.safeParse(raw)
if (!task.success) return null
if (task.data.exp <= Math.floor(Date.now() / 1000)) return null
return task.data
}
async function hashDownloadToken(platform: Platform, token: string): Promise<string> {
const key = await hmacKey(secret(platform))
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`hash:${token}`))
return base64UrlEncodeBytes(new Uint8Array(signature))
}
return {
async signDownloadToken(platform, claims) {
const payload = base64UrlEncode(JSON.stringify(claims))
const signature = await signPayload(platform, payload)
return `${payload}.${signature}`
},
verifyDownloadToken,
hashDownloadToken,
async resolveDownloaderToken(platform, token) {
const claims = await verifyDownloadToken(platform, token)
if (!claims || claims.typ !== 'downloader') return null
const hash = await hashDownloadToken(platform, token)
const rows = await platform.db
.select({
id: downloaders.id,
enabled: downloaders.enabled,
tokenHash: downloaders.tokenHash,
tokenJti: downloaders.tokenJti,
})
.from(downloaders)
.where(eq(downloaders.id, claims.downloaderId))
.limit(1)
const row = rows[0]
if (!row?.enabled || row.tokenHash !== hash || row.tokenJti !== claims.jti) return null
return { downloaderId: row.id }
},
async resolveTaskUploadToken(db, platform, token): Promise<TaskUploadTokenClaims | null> {
const claims = await verifyDownloadToken(platform, token)
if (!claims || claims.typ !== 'download-task-upload') return null
const rows = await db
.select({
id: downloadTasks.id,
assignedDownloaderId: downloadTasks.assignedDownloaderId,
status: downloadTasks.status,
orgId: downloadTasks.orgId,
targetFolder: downloadTasks.targetFolder,
createdByUserId: downloadTasks.createdByUserId,
})
.from(downloadTasks)
.where(eq(downloadTasks.id, claims.taskId))
.limit(1)
const task = rows[0]
if (!task) return null
if (task.assignedDownloaderId !== claims.downloaderId) return null
if (
task.orgId !== claims.orgId ||
task.targetFolder !== claims.targetFolder ||
task.createdByUserId !== claims.createdByUserId
) {
return null
}
if (!['assigned', 'downloading', 'uploading'].includes(task.status)) return null
return claims
},
}
}
async function signPayload(platform: Platform, payload: string): Promise<string> {
const key = await hmacKey(secret(platform))
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
return base64UrlEncodeBytes(new Uint8Array(signature))
}
async function hmacKey(value: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', new TextEncoder().encode(value), { name: 'HMAC', hash: 'SHA-256' }, false, [
'sign',
])
}
function secret(platform: Platform): string {
const value = platform.getEnv('BETTER_AUTH_SECRET') ?? platform.getEnv('DOWNLOAD_TOKEN_SECRET')
if (!value) throw new Error('download_token_secret_missing')
return value
}
function base64UrlEncode(value: string): string {
return base64UrlEncodeBytes(new TextEncoder().encode(value))
}
function base64UrlEncodeBytes(bytes: Uint8Array): string {
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function base64UrlDecode(value: string): string {
const padded = value
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(value.length / 4) * 4, '=')
return atob(padded)
}
+227
View File
@@ -0,0 +1,227 @@
import type { Downloader } from '@shared/types'
import { and, asc, desc, eq, gt, gte, inArray, lt } from 'drizzle-orm'
import { downloaders } from '../../db/schema'
import type { Database } from '../../platform/interface'
import {
type CreateDownloaderRecordInput,
DownloadError,
type DownloaderHeartbeatFields,
type DownloaderRecord,
type DownloaderRepo,
type UpdateDownloaderFields,
} from '../../usecases/ports'
type DownloaderRow = typeof downloaders.$inferSelect
function parseCapabilities(value: string): string[] {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
} catch {
return []
}
}
function toRecord(row: DownloaderRow): DownloaderRecord {
return {
id: row.id,
name: row.name,
tokenHash: row.tokenHash,
tokenJti: row.tokenJti,
status: row.status,
enabled: row.enabled,
version: row.version,
hostname: row.hostname,
platform: row.platform,
arch: row.arch,
engine: row.engine,
capabilities: parseCapabilities(row.capabilities),
maxConcurrentTasks: row.maxConcurrentTasks,
currentTasks: row.currentTasks,
downloadBps: row.downloadBps,
uploadBps: row.uploadBps,
freeDiskBytes: row.freeDiskBytes,
remoteDownloadCreditBillingEnabled: row.remoteDownloadCreditBillingEnabled,
remoteDownloadCreditUnitBytes: row.remoteDownloadCreditUnitBytes,
remoteDownloadCreditPerUnit: row.remoteDownloadCreditPerUnit,
lastHeartbeatAt: row.lastHeartbeatAt,
createdBy: row.createdBy,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
function toDownloader(row: DownloaderRow): Downloader {
return {
id: row.id,
name: row.name,
status: row.enabled ? (row.status as Downloader['status']) : 'disabled',
enabled: row.enabled,
version: row.version,
hostname: row.hostname,
platform: row.platform,
arch: row.arch,
engine: row.engine as Downloader['engine'],
capabilities: parseCapabilities(row.capabilities),
maxConcurrentTasks: row.maxConcurrentTasks,
currentTasks: row.currentTasks,
downloadBps: row.downloadBps,
uploadBps: row.uploadBps,
freeDiskBytes: row.freeDiskBytes,
remoteDownloadCreditBillingEnabled: row.remoteDownloadCreditBillingEnabled,
remoteDownloadCreditUnitBytes: row.remoteDownloadCreditUnitBytes,
remoteDownloadCreditPerUnit: row.remoteDownloadCreditPerUnit,
lastHeartbeatAt: row.lastHeartbeatAt?.toISOString() ?? null,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
const DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT = 1
export function createDownloaderRepo(db: Database): DownloaderRepo {
async function findRow(id: string): Promise<DownloaderRow | null> {
const rows = await db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1)
return rows[0] ?? null
}
return {
async insert(input: CreateDownloaderRecordInput) {
await db.insert(downloaders).values({
id: input.id,
name: input.name,
tokenHash: input.tokenHash,
tokenJti: input.tokenJti,
status: 'offline',
enabled: true,
version: input.version,
hostname: input.hostname,
platform: input.platform,
arch: input.arch,
engine: input.engine,
capabilities: JSON.stringify(input.capabilities),
maxConcurrentTasks: input.maxConcurrentTasks,
currentTasks: input.currentTasks,
downloadBps: input.downloadBps,
uploadBps: input.uploadBps,
freeDiskBytes: input.freeDiskBytes,
remoteDownloadCreditBillingEnabled: false,
remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes,
remoteDownloadCreditPerUnit: DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT,
lastHeartbeatAt: null,
createdBy: input.createdBy,
createdAt: input.now,
updatedAt: input.now,
})
},
async list() {
const rows = await db.select().from(downloaders).orderBy(desc(downloaders.createdAt))
return rows.map(toDownloader)
},
async get(id) {
const row = await findRow(id)
if (!row) throw new DownloadError('not_found')
return toDownloader(row)
},
async getRecord(id) {
const row = await findRow(id)
if (!row) throw new DownloadError('not_found')
return toRecord(row)
},
async findRecord(id) {
const row = await findRow(id)
return row ? toRecord(row) : null
},
async update(id, fields: UpdateDownloaderFields, now) {
await db
.update(downloaders)
.set({
...(fields.name !== undefined ? { name: fields.name } : {}),
...(fields.enabled !== undefined
? { enabled: fields.enabled, status: fields.enabled ? 'offline' : 'disabled' }
: {}),
...(fields.remoteDownloadCreditBillingEnabled !== undefined
? { remoteDownloadCreditBillingEnabled: fields.remoteDownloadCreditBillingEnabled }
: {}),
...(fields.remoteDownloadCreditUnitBytes !== undefined
? { remoteDownloadCreditUnitBytes: fields.remoteDownloadCreditUnitBytes }
: {}),
...(fields.remoteDownloadCreditPerUnit !== undefined
? { remoteDownloadCreditPerUnit: fields.remoteDownloadCreditPerUnit }
: {}),
updatedAt: now,
})
.where(eq(downloaders.id, id))
},
async recordHeartbeat(id, fields: DownloaderHeartbeatFields, online, now) {
await db
.update(downloaders)
.set({
status: online ? 'online' : 'disabled',
version: fields.version,
hostname: fields.hostname,
platform: fields.platform,
arch: fields.arch,
engine: fields.engine,
capabilities: JSON.stringify(fields.capabilities),
maxConcurrentTasks: fields.maxConcurrentTasks,
currentTasks: fields.currentTasks,
downloadBps: fields.downloadBps,
uploadBps: fields.uploadBps,
freeDiskBytes: fields.freeDiskBytes,
lastHeartbeatAt: now,
updatedAt: now,
})
.where(eq(downloaders.id, id))
},
async delete(id) {
await db.delete(downloaders).where(eq(downloaders.id, id))
},
async listAssignmentCandidates(leaseCutoff) {
const rows = await db
.select()
.from(downloaders)
.where(
and(
eq(downloaders.enabled, true),
eq(downloaders.status, 'online'),
gte(downloaders.lastHeartbeatAt, leaseCutoff),
gt(downloaders.maxConcurrentTasks, downloaders.currentTasks),
),
)
.orderBy(asc(downloaders.currentTasks), asc(downloaders.downloadBps))
return rows.map(toRecord)
},
async listStaleIds(leaseCutoff) {
const rows = await db
.select({ id: downloaders.id })
.from(downloaders)
.where(
and(
eq(downloaders.enabled, true),
eq(downloaders.status, 'online'),
lt(downloaders.lastHeartbeatAt, leaseCutoff),
),
)
return rows.map((row) => row.id)
},
async markStaleOffline(ids, now) {
if (ids.length === 0) return
await db
.update(downloaders)
.set({ status: 'offline', currentTasks: 0, downloadBps: 0, uploadBps: 0, updatedAt: now })
.where(inArray(downloaders.id, ids))
},
}
}
@@ -0,0 +1,37 @@
import { eq } from 'drizzle-orm'
import { imageHostingConfigs } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { ImageHostingConfigRecord, ImageHostingConfigRepo } from '../../usecases/ports'
export function createImageHostingConfigRepo(db: Database): ImageHostingConfigRepo {
return {
async getByOrg(orgId) {
const rows = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1)
return (rows[0] as ImageHostingConfigRecord | undefined) ?? null
},
async create(input) {
const now = new Date()
await db.insert(imageHostingConfigs).values({
orgId: input.orgId,
customDomain: input.customDomain,
cfHostnameId: input.cfHostnameId,
domainVerifiedAt: null,
refererAllowlist: input.refererAllowlist,
createdAt: now,
updatedAt: now,
})
},
async update(orgId, set) {
await db
.update(imageHostingConfigs)
.set({ ...set, updatedAt: new Date() })
.where(eq(imageHostingConfigs.orgId, orgId))
},
async delete(orgId) {
await db.delete(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId))
},
}
}
+206
View File
@@ -0,0 +1,206 @@
import { and, asc, eq, gt, isNotNull, like, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { imageHostingConfigs, imageHostings } from '../../db/schema'
import { mimeToExt } from '../../lib/mime-utils'
import type { Database } from '../../platform/interface'
import type {
CreateImageHostingInput,
ImageHostingRecord,
ImageHostingRepo,
ImageResolution,
} from '../../usecases/ports'
type ImageHostingRow = typeof imageHostings.$inferSelect
const MAX_COLLISION_RETRIES = 5
function toRecord(row: ImageHostingRow): ImageHostingRecord {
return row as unknown as ImageHostingRecord
}
function parseRefererAllowlist(value: string | null): string[] {
return value ? (JSON.parse(value) as string[]) : []
}
function splitStemExt(filename: string): { stem: string; ext: string } {
const dot = filename.lastIndexOf('.')
if (dot <= 0) return { stem: filename, ext: '' }
return { stem: filename.slice(0, dot), ext: filename.slice(dot) }
}
function randomHex4(): string {
return Math.floor(Math.random() * 0x10000)
.toString(16)
.padStart(4, '0')
}
export function createImageHostingRepo(db: Database): ImageHostingRepo {
async function resolveUniquePath(orgId: string, requestedPath: string): Promise<string> {
const rows = await db
.select({ path: imageHostings.path })
.from(imageHostings)
.where(and(eq(imageHostings.orgId, orgId), eq(imageHostings.path, requestedPath)))
if (rows.length === 0) return requestedPath
const slashIdx = requestedPath.lastIndexOf('/')
const basename = slashIdx >= 0 ? requestedPath.slice(slashIdx + 1) : requestedPath
const prefix = slashIdx >= 0 ? requestedPath.slice(0, slashIdx + 1) : ''
const { stem, ext } = splitStemExt(basename)
for (let i = 0; i < MAX_COLLISION_RETRIES; i++) {
const candidate = `${prefix}${stem}-${randomHex4()}${ext}`
const conflict = await db
.select({ path: imageHostings.path })
.from(imageHostings)
.where(and(eq(imageHostings.orgId, orgId), eq(imageHostings.path, candidate)))
if (conflict.length === 0) return candidate
}
// Exhausted retries — use nanoid suffix as fallback
return `${prefix}${stem}-${nanoid(4)}${ext}`
}
return {
async resolveActiveByToken(token): Promise<ImageResolution | null> {
const rows = await db.select().from(imageHostings).where(eq(imageHostings.token, token)).limit(1)
const row = rows[0]
if (!row || row.status !== 'active') return null
const configRows = await db
.select()
.from(imageHostingConfigs)
.where(eq(imageHostingConfigs.orgId, row.orgId))
.limit(1)
return {
image: toRecord(row),
refererAllowlist: parseRefererAllowlist(configRows[0]?.refererAllowlist ?? null),
}
},
async resolveCustomDomain(host) {
const rows = await db
.select({ orgId: imageHostingConfigs.orgId })
.from(imageHostingConfigs)
.where(and(eq(imageHostingConfigs.customDomain, host), isNotNull(imageHostingConfigs.domainVerifiedAt)))
.limit(1)
return rows[0]?.orgId ?? null
},
async resolveActiveByOrgPath(orgId, path): Promise<ImageResolution | null> {
const rows = await db
.select()
.from(imageHostings)
.where(and(eq(imageHostings.orgId, orgId), eq(imageHostings.path, path), eq(imageHostings.status, 'active')))
.limit(1)
if (!rows[0]) return null
const configRows = await db
.select()
.from(imageHostingConfigs)
.where(eq(imageHostingConfigs.orgId, orgId))
.limit(1)
if (configRows.length === 0) return null
return {
image: toRecord(rows[0]),
refererAllowlist: parseRefererAllowlist(configRows[0].refererAllowlist),
}
},
async incrementAccessCount(id) {
await db.run(
sql`UPDATE image_hostings SET access_count = access_count + 1, last_accessed_at = ${Date.now()} WHERE id = ${id}`,
)
},
async create(input: CreateImageHostingInput) {
const id = nanoid(12)
const token = `ih_${nanoid(10)}`
const ext = mimeToExt(input.mime)
const storageKey = `ih/${input.orgId}/${id}.${ext}`
const now = new Date()
const resolvedPath = await resolveUniquePath(input.orgId, input.path)
const row: ImageHostingRow = {
id,
orgId: input.orgId,
token,
path: resolvedPath,
storageId: input.storageId,
storageKey,
size: input.size,
mime: input.mime,
width: null,
height: null,
status: input.status,
accessCount: 0,
lastAccessedAt: null,
createdAt: now,
}
await db.insert(imageHostings).values(row)
return toRecord(row)
},
async get(id, orgId) {
const rows = await db
.select()
.from(imageHostings)
.where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId)))
return rows[0] ? toRecord(rows[0]) : null
},
async list(orgId, opts) {
const conditions = [eq(imageHostings.orgId, orgId), eq(imageHostings.status, 'active')]
if (opts.pathPrefix) {
conditions.push(like(imageHostings.path, `${opts.pathPrefix}%`))
}
if (opts.cursor) {
// cursor is base64url-encoded ISO timestamp
try {
const ts = new Date(Buffer.from(opts.cursor, 'base64url').toString())
if (!Number.isNaN(ts.getTime())) {
conditions.push(gt(imageHostings.createdAt, ts))
}
} catch {
// ignore invalid cursor
}
}
const items = await db
.select()
.from(imageHostings)
.where(and(...conditions))
.orderBy(asc(imageHostings.createdAt))
.limit(opts.limit + 1)
const hasMore = items.length > opts.limit
const page = hasMore ? items.slice(0, opts.limit) : items
const nextCursor =
hasMore && page.length > 0
? Buffer.from(page[page.length - 1].createdAt.toISOString()).toString('base64url')
: null
return { items: page.map(toRecord), nextCursor }
},
async setActive(id, orgId) {
const updated = await db
.update(imageHostings)
.set({ status: 'active' })
.where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId), eq(imageHostings.status, 'draft')))
.returning({ id: imageHostings.id })
return updated.length > 0
},
async delete(id, orgId) {
await db.delete(imageHostings).where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId)))
},
}
}
+36
View File
@@ -0,0 +1,36 @@
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { systemOptions } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { InstanceRepo } from '../../usecases/ports'
const INSTANCE_ID_KEY = 'instance_id'
export function createInstanceRepo(db: Database): InstanceRepo {
return {
async getOrCreateInstanceId() {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, INSTANCE_ID_KEY))
.limit(1)
if (rows[0]?.value) return rows[0].value
const id = nanoid(21)
await db
.insert(systemOptions)
.values({ key: INSTANCE_ID_KEY, value: id, public: false })
.onConflictDoUpdate({ target: systemOptions.key, set: { value: id } })
return id
},
async getInstanceDisplayName() {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, 'site_title'))
.limit(1)
return rows[0]?.value ?? 'ZPan'
},
}
}
@@ -1,36 +1,30 @@
import { describe, expect, it } from 'vitest'
import { createTestApp } from '../test/setup.js'
import {
deleteInviteCode,
generateInviteCodes,
listInviteCodes,
redeemInviteCode,
validateInviteCode,
} from './invite.js'
import { createTestApp } from '../../test/setup.js'
import { createInviteRepo } from './invite.js'
describe('generateInviteCodes', () => {
it('returns the requested number of codes', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-1', 5)
const codes = await createInviteRepo(db).generate('admin-1', 5)
expect(codes).toHaveLength(5)
})
it('returns one code when count is 1', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-1', 1)
const codes = await createInviteRepo(db).generate('admin-1', 1)
expect(codes).toHaveLength(1)
})
it('generates unique codes for each entry', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-1', 10)
const codes = await createInviteRepo(db).generate('admin-1', 10)
const uniqueCodes = new Set(codes.map((c) => c.code))
expect(uniqueCodes.size).toBe(10)
})
it('each code has an 8-character uppercase alphanumeric code field', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-1', 3)
const codes = await createInviteRepo(db).generate('admin-1', 3)
for (const code of codes) {
expect(code.code).toMatch(/^[0-9A-Z]{8}$/)
}
@@ -38,7 +32,7 @@ describe('generateInviteCodes', () => {
it('sets createdBy to the provided admin user id on all codes', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-42', 3)
const codes = await createInviteRepo(db).generate('admin-42', 3)
for (const code of codes) {
expect(code.createdBy).toBe('admin-42')
}
@@ -46,7 +40,7 @@ describe('generateInviteCodes', () => {
it('sets usedBy and usedAt to null on fresh codes', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-1', 2)
const codes = await createInviteRepo(db).generate('admin-1', 2)
for (const code of codes) {
expect(code.usedBy).toBeNull()
expect(code.usedAt).toBeNull()
@@ -55,7 +49,7 @@ describe('generateInviteCodes', () => {
it('sets expiresAt to null when not provided', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-1', 2)
const codes = await createInviteRepo(db).generate('admin-1', 2)
for (const code of codes) {
expect(code.expiresAt).toBeNull()
}
@@ -64,7 +58,7 @@ describe('generateInviteCodes', () => {
it('propagates expiresAt to all generated codes', async () => {
const { db } = await createTestApp()
const expiry = new Date(Date.now() + 86400000)
const codes = await generateInviteCodes(db, 'admin-1', 3, expiry)
const codes = await createInviteRepo(db).generate('admin-1', 3, expiry)
for (const code of codes) {
expect(code.expiresAt).not.toBeNull()
}
@@ -72,9 +66,9 @@ describe('generateInviteCodes', () => {
it('persists codes to the database', async () => {
const { db } = await createTestApp()
const codes = await generateInviteCodes(db, 'admin-1', 2)
const codes = await createInviteRepo(db).generate('admin-1', 2)
for (const code of codes) {
const result = await validateInviteCode(db, code.code)
const result = await createInviteRepo(db).validate(code.code)
expect(result.valid).toBe(true)
}
})
@@ -83,23 +77,23 @@ describe('generateInviteCodes', () => {
describe('validateInviteCode', () => {
it('returns valid:true for an unused, unexpired code', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
const result = await validateInviteCode(db, row.code)
const [row] = await createInviteRepo(db).generate('admin-1', 1)
const result = await createInviteRepo(db).validate(row.code)
expect(result).toEqual({ valid: true })
})
it('returns valid:false with an error for a nonexistent code', async () => {
const { db } = await createTestApp()
const result = await validateInviteCode(db, 'NOSUCHCD')
const result = await createInviteRepo(db).validate('NOSUCHCD')
expect(result.valid).toBe(false)
expect(result.error).toBeTruthy()
})
it('returns valid:false with an error for a used code', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
await redeemInviteCode(db, row.code, 'user-99')
const result = await validateInviteCode(db, row.code)
const [row] = await createInviteRepo(db).generate('admin-1', 1)
await createInviteRepo(db).redeem(row.code, 'user-99')
const result = await createInviteRepo(db).validate(row.code)
expect(result.valid).toBe(false)
expect(result.error).toBeTruthy()
})
@@ -107,8 +101,8 @@ describe('validateInviteCode', () => {
it('returns valid:false with an error for an expired code', async () => {
const { db } = await createTestApp()
const pastDate = new Date(Date.now() - 1000)
const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate)
const result = await validateInviteCode(db, row.code)
const [row] = await createInviteRepo(db).generate('admin-1', 1, pastDate)
const result = await createInviteRepo(db).validate(row.code)
expect(result.valid).toBe(false)
expect(result.error).toBeTruthy()
})
@@ -116,8 +110,8 @@ describe('validateInviteCode', () => {
it('returns valid:true for a code that has not yet expired', async () => {
const { db } = await createTestApp()
const futureDate = new Date(Date.now() + 86400000)
const [row] = await generateInviteCodes(db, 'admin-1', 1, futureDate)
const result = await validateInviteCode(db, row.code)
const [row] = await createInviteRepo(db).generate('admin-1', 1, futureDate)
const result = await createInviteRepo(db).validate(row.code)
expect(result.valid).toBe(true)
})
})
@@ -125,46 +119,46 @@ describe('validateInviteCode', () => {
describe('redeemInviteCode', () => {
it('returns ok when redeeming a valid unused code', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
const result = await redeemInviteCode(db, row.code, 'user-55')
const [row] = await createInviteRepo(db).generate('admin-1', 1)
const result = await createInviteRepo(db).redeem(row.code, 'user-55')
expect(result).toBe('ok')
})
it('marks the code as used so it cannot be validated again', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
await redeemInviteCode(db, row.code, 'user-55')
const check = await validateInviteCode(db, row.code)
const [row] = await createInviteRepo(db).generate('admin-1', 1)
await createInviteRepo(db).redeem(row.code, 'user-55')
const check = await createInviteRepo(db).validate(row.code)
expect(check.valid).toBe(false)
})
it('returns not_found for a nonexistent code', async () => {
const { db } = await createTestApp()
const result = await redeemInviteCode(db, 'NOSUCHCD', 'user-55')
const result = await createInviteRepo(db).redeem('NOSUCHCD', 'user-55')
expect(result).toBe('not_found')
})
it('returns already_used when redeeming a previously redeemed code', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
await redeemInviteCode(db, row.code, 'user-55')
const result = await redeemInviteCode(db, row.code, 'user-99')
const [row] = await createInviteRepo(db).generate('admin-1', 1)
await createInviteRepo(db).redeem(row.code, 'user-55')
const result = await createInviteRepo(db).redeem(row.code, 'user-99')
expect(result).toBe('already_used')
})
it('returns expired for an expired code', async () => {
const { db } = await createTestApp()
const pastDate = new Date(Date.now() - 1000)
const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate)
const result = await redeemInviteCode(db, row.code, 'user-55')
const [row] = await createInviteRepo(db).generate('admin-1', 1, pastDate)
const result = await createInviteRepo(db).redeem(row.code, 'user-55')
expect(result).toBe('expired')
})
it('sets usedAt to a non-null timestamp after redemption', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
await redeemInviteCode(db, row.code, 'user-55')
const check = await validateInviteCode(db, row.code)
const [row] = await createInviteRepo(db).generate('admin-1', 1)
await createInviteRepo(db).redeem(row.code, 'user-55')
const check = await createInviteRepo(db).validate(row.code)
expect(check.error).toContain('used')
})
})
@@ -172,46 +166,46 @@ describe('redeemInviteCode', () => {
describe('listInviteCodes', () => {
it('returns empty items and total 0 when no codes exist', async () => {
const { db } = await createTestApp()
const result = await listInviteCodes(db, 1, 20)
const result = await createInviteRepo(db).list(1, 20)
expect(result).toEqual({ items: [], total: 0 })
})
it('returns all codes when fewer than pageSize', async () => {
const { db } = await createTestApp()
await generateInviteCodes(db, 'admin-1', 3)
const result = await listInviteCodes(db, 1, 20)
await createInviteRepo(db).generate('admin-1', 3)
const result = await createInviteRepo(db).list(1, 20)
expect(result.total).toBe(3)
expect(result.items).toHaveLength(3)
})
it('paginates correctly — page 1 returns first pageSize items', async () => {
const { db } = await createTestApp()
await generateInviteCodes(db, 'admin-1', 5)
const result = await listInviteCodes(db, 1, 3)
await createInviteRepo(db).generate('admin-1', 5)
const result = await createInviteRepo(db).list(1, 3)
expect(result.total).toBe(5)
expect(result.items).toHaveLength(3)
})
it('paginates correctly — page 2 returns remaining items', async () => {
const { db } = await createTestApp()
await generateInviteCodes(db, 'admin-1', 5)
const result = await listInviteCodes(db, 2, 3)
await createInviteRepo(db).generate('admin-1', 5)
const result = await createInviteRepo(db).list(2, 3)
expect(result.total).toBe(5)
expect(result.items).toHaveLength(2)
})
it('returns empty items on a page beyond total count', async () => {
const { db } = await createTestApp()
await generateInviteCodes(db, 'admin-1', 2)
const result = await listInviteCodes(db, 5, 20)
await createInviteRepo(db).generate('admin-1', 2)
const result = await createInviteRepo(db).list(5, 20)
expect(result.total).toBe(2)
expect(result.items).toHaveLength(0)
})
it('orders results by createdAt descending', async () => {
const { db } = await createTestApp()
await generateInviteCodes(db, 'admin-1', 3)
const result = await listInviteCodes(db, 1, 20)
await createInviteRepo(db).generate('admin-1', 3)
const result = await createInviteRepo(db).list(1, 20)
const timestamps = result.items.map((item) => item.createdAt.getTime())
const sorted = [...timestamps].sort((a, b) => b - a)
expect(timestamps).toEqual(sorted)
@@ -221,30 +215,30 @@ describe('listInviteCodes', () => {
describe('deleteInviteCode', () => {
it('returns ok and removes the code when it exists and is unused', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
const result = await deleteInviteCode(db, row.id)
const [row] = await createInviteRepo(db).generate('admin-1', 1)
const result = await createInviteRepo(db).delete(row.id)
expect(result).toBe('ok')
})
it('removes the code from the database after deletion', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
await deleteInviteCode(db, row.id)
const check = await validateInviteCode(db, row.code)
const [row] = await createInviteRepo(db).generate('admin-1', 1)
await createInviteRepo(db).delete(row.id)
const check = await createInviteRepo(db).validate(row.code)
expect(check.valid).toBe(false)
})
it('returns not_found for a nonexistent code id', async () => {
const { db } = await createTestApp()
const result = await deleteInviteCode(db, 'NOSUCHID')
const result = await createInviteRepo(db).delete('NOSUCHID')
expect(result).toBe('not_found')
})
it('returns already_used for a code that has been redeemed', async () => {
const { db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
await redeemInviteCode(db, row.code, 'user-99')
const result = await deleteInviteCode(db, row.id)
const [row] = await createInviteRepo(db).generate('admin-1', 1)
await createInviteRepo(db).redeem(row.code, 'user-99')
const result = await createInviteRepo(db).delete(row.id)
expect(result).toBe('already_used')
})
})
+79
View File
@@ -0,0 +1,79 @@
import { and, count, desc, eq, gt, isNull, or } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { inviteCodes } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { InviteCodeRecord, InviteRepo } from '../../usecases/ports'
const generateCode = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8)
export function createInviteRepo(db: Database): InviteRepo {
return {
async generate(adminUserId, quantity, expiresAt) {
const now = new Date()
const rows: InviteCodeRecord[] = Array.from({ length: quantity }, () => ({
id: nanoid(),
code: generateCode(),
createdBy: adminUserId,
usedBy: null,
usedAt: null,
expiresAt: expiresAt ?? null,
createdAt: now,
}))
await db.insert(inviteCodes).values(rows)
return rows
},
async validate(code) {
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.code, code))
const row = rows[0]
if (!row) return { valid: false, error: 'Invalid invite code' }
if (row.usedBy) return { valid: false, error: 'Invite code already used' }
if (row.expiresAt && row.expiresAt < new Date()) return { valid: false, error: 'Invite code expired' }
return { valid: true }
},
async redeem(code, userId) {
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.code, code))
const row = rows[0]
if (!row) return 'not_found'
if (row.usedBy) return 'already_used'
if (row.expiresAt && row.expiresAt < new Date()) return 'expired'
const updated = await db
.update(inviteCodes)
.set({ usedBy: userId, usedAt: new Date() })
.where(
and(
eq(inviteCodes.code, code),
isNull(inviteCodes.usedBy),
or(isNull(inviteCodes.expiresAt), gt(inviteCodes.expiresAt, new Date())),
),
)
.returning({ id: inviteCodes.id })
return updated.length > 0 ? 'ok' : 'already_used'
},
async list(page, pageSize) {
const [totalResult, items] = await Promise.all([
db.select({ count: count() }).from(inviteCodes),
db
.select()
.from(inviteCodes)
.orderBy(desc(inviteCodes.createdAt))
.limit(pageSize)
.offset((page - 1) * pageSize),
])
return { items, total: totalResult[0]?.count ?? 0 }
},
async delete(codeId) {
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.id, codeId))
const row = rows[0]
if (!row) return 'not_found'
if (row.usedBy) return 'already_used'
await db.delete(inviteCodes).where(eq(inviteCodes.id, codeId))
return 'ok'
},
}
}
@@ -1,34 +1,22 @@
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { licenseBindings } from '../db/schema'
import type { Database } from '../platform/interface'
import { executeWriteTransaction } from '../services/db-transaction'
import { licenseBindings } from '../../db/schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
import type {
CreateLicenseBindingInput,
LicenseBindingRepo,
LicenseBindingStatus,
LicenseState,
UpdateLicenseBindingInput,
} from '../../usecases/ports'
export type LicenseBindingStatus = 'active' | 'disconnected' | 'revoked'
export interface LicenseState {
id: string
cloudBindingId: string
cloudStoreId: string | null
instanceId: string
cloudAccountId: string
cloudAccountEmail: string | null
status: LicenseBindingStatus
refreshToken: string | null
cachedCert: string | null
cachedExpiresAt: number | null
boundAt: number
disconnectedAt: number | null
lastRefreshAt: number | null
lastRefreshError: string | null
}
export async function loadLicenseState(db: Database): Promise<LicenseState> {
async function loadLicenseState(db: Database): Promise<LicenseState> {
const row = await loadActiveLicenseBinding(db)
return row ?? emptyLicenseState()
}
export async function loadActiveLicenseBinding(db: Database): Promise<LicenseState | null> {
async function loadActiveLicenseBinding(db: Database): Promise<LicenseState | null> {
const rows = await db.select().from(licenseBindings).where(eq(licenseBindings.status, 'active')).limit(1)
const row = rows[0]
if (!row) return null
@@ -51,20 +39,7 @@ export async function loadActiveLicenseBinding(db: Database): Promise<LicenseSta
}
}
export async function createLicenseBinding(
db: Database,
input: {
cloudBindingId: string
cloudStoreId?: string | null
instanceId: string
cloudAccountId: string
cloudAccountEmail?: string | null
refreshToken: string
cachedCert: string
cachedExpiresAt: number
lastRefreshAt: number
},
): Promise<void> {
async function createLicenseBinding(db: Database, input: CreateLicenseBindingInput): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await executeWriteTransaction(db, [
db
@@ -97,18 +72,7 @@ export async function createLicenseBinding(
])
}
export async function updateLicenseBindingAfterRefresh(
db: Database,
input: {
id: string
refreshToken: string
cloudStoreId?: string | null
cachedCert: string
cachedExpiresAt: number
cloudAccountEmail?: string | null
lastRefreshAt: number
},
): Promise<void> {
async function updateLicenseBindingAfterRefresh(db: Database, input: UpdateLicenseBindingInput): Promise<void> {
await db
.update(licenseBindings)
.set({
@@ -124,14 +88,14 @@ export async function updateLicenseBindingAfterRefresh(
.where(eq(licenseBindings.id, input.id))
}
export async function setLicenseRefreshError(db: Database, id: string, error: string): Promise<void> {
async function setLicenseRefreshError(db: Database, id: string, error: string): Promise<void> {
await db
.update(licenseBindings)
.set({ lastRefreshError: error, updatedAt: Math.floor(Date.now() / 1000) })
.where(eq(licenseBindings.id, id))
}
export async function clearLicenseBinding(db: Database, status: LicenseBindingStatus = 'disconnected'): Promise<void> {
async function clearLicenseBinding(db: Database, status: LicenseBindingStatus = 'disconnected'): Promise<void> {
const now = Math.floor(Date.now() / 1000)
await db
.update(licenseBindings)
@@ -164,3 +128,14 @@ function emptyLicenseState(): LicenseState {
lastRefreshError: null,
}
}
export function createLicenseBindingRepo(db: Database): LicenseBindingRepo {
return {
loadLicenseState: () => loadLicenseState(db),
loadActiveLicenseBinding: () => loadActiveLicenseBinding(db),
createLicenseBinding: (input) => createLicenseBinding(db, input),
updateLicenseBindingAfterRefresh: (input) => updateLicenseBindingAfterRefresh(db, input),
setLicenseRefreshError: (id, error) => setLicenseRefreshError(db, id, error),
clearLicenseBinding: (status) => clearLicenseBinding(db, status),
}
}
@@ -6,13 +6,56 @@
import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { ObjectStatus } from '../../shared/constants'
import { createTestApp } from '../test/setup.js'
import { cancelDraftMatter, confirmUpload, copyMatter, createMatter, restoreMatter, updateMatter } from './matter.js'
import { NameConflictError } from './matter-name-conflict.js'
import { ObjectStatus } from '../../../shared/constants'
import { createTestApp } from '../../test/setup.js'
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../../usecases/matter'
import type {
ConflictStrategy,
CopyMatterOptions,
CreateMatterInput,
Matter,
UpdateMatterInput,
} from '../../usecases/ports'
import { NameConflictError } from '../../usecases/ports'
import { createActivityRepo } from './activity.js'
import { createMatterRepo } from './matter.js'
import { createQuotaRepo } from './quota.js'
import { createStorageUsageRepo } from './storage-usage.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
// Thin adapters preserving the former matter service signatures so these
// behavioral tests exercise the migrated MatterRepo + confirmUpload usecase
// unchanged.
function createMatter(db: TestDb, input: CreateMatterInput): Promise<Matter> {
return createMatterRepo(db).create(input)
}
function updateMatter(db: TestDb, id: string, orgId: string, input: UpdateMatterInput, userId?: string) {
return createMatterRepo(db).update(id, orgId, input, userId)
}
function copyMatter(db: TestDb, source: Matter, targetParent: string, newObject: string, opts?: CopyMatterOptions) {
return createMatterRepo(db).copy(source, targetParent, newObject, opts)
}
function restoreMatter(db: TestDb, orgId: string, id: string, userId?: string, onConflict?: ConflictStrategy) {
return createMatterRepo(db).restore(orgId, id, userId, onConflict)
}
function cancelDraftMatter(db: TestDb, id: string, orgId: string, userId?: string) {
return createMatterRepo(db).cancelDraft(id, orgId, userId)
}
function confirmUpload(db: TestDb, id: string, orgId: string, opts: ConfirmUploadOptions = {}) {
return confirmUploadUsecase(
{
matter: createMatterRepo(db),
quota: createQuotaRepo(db),
storageUsage: createStorageUsageRepo(db),
activity: createActivityRepo(db),
},
id,
orgId,
opts,
)
}
const STORAGE_ID = 'st-conflict'
async function insertStorage(db: TestDb) {
@@ -1,18 +1,43 @@
import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { DirType, ObjectStatus } from '../../shared/constants'
import { createTestApp } from '../test/setup.js'
import {
applyConflictResolution,
commitConflictPlan,
findActiveConflict,
NameConflictError,
planConflictResolution,
} from './matter-name-conflict.js'
import { DirType, ObjectStatus } from '../../../shared/constants'
import { createTestApp } from '../../test/setup.js'
import type { ConflictPlan, ConflictResolveOptions, ConflictStrategy } from '../../usecases/ports'
import { NameConflictError } from '../../usecases/ports'
import { createMatterRepo } from './matter.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
// Thin adapters preserving the former matter-name-conflict service signature so
// these behavioral tests exercise the migrated MatterRepo unchanged.
function findActiveConflict(db: TestDb, orgId: string, parent: string, name: string, excludeId?: string) {
return createMatterRepo(db).findActiveConflict(orgId, parent, name, excludeId)
}
function applyConflictResolution(
db: TestDb,
orgId: string,
parent: string,
name: string,
strategy: ConflictStrategy,
options?: ConflictResolveOptions,
) {
return createMatterRepo(db).applyConflictResolution(orgId, parent, name, strategy, options)
}
function planConflictResolution(
db: TestDb,
orgId: string,
parent: string,
name: string,
strategy: ConflictStrategy,
options?: ConflictResolveOptions,
) {
return createMatterRepo(db).planConflictResolution(orgId, parent, name, strategy, options)
}
function commitConflictPlan(db: TestDb, orgId: string, plan: ConflictPlan, userId?: string) {
return createMatterRepo(db).commitConflictPlan(orgId, plan, userId)
}
async function insertStorage(db: TestDb, id = 'st-1') {
const now = Date.now()
await db.run(sql`
@@ -1,16 +1,42 @@
import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
import { createTestApp } from '../test/setup.js'
import { getEffectiveQuota, hasQuotaForBytes } from './effective-quota.js'
import { confirmUpload, listTrashedRoots, updateMatter } from './matter.js'
import { orgQuotaEntitlements, orgQuotas } from '../../db/schema.js'
import { createTestApp } from '../../test/setup.js'
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../../usecases/matter'
import type { UpdateMatterInput } from '../../usecases/ports'
import {
reconcileStorageUsage,
reserveStorageUsage,
StorageQuotaExceededError,
withStorageUsageReservation,
} from './storage-usage.js'
} from '../../usecases/storage-usage.js'
import { createActivityRepo } from './activity.js'
import { createMatterRepo } from './matter.js'
import { createQuotaRepo } from './quota.js'
import { createStorageUsageRepo } from './storage-usage.js'
type TestDbForRepo = Awaited<ReturnType<typeof createTestApp>>['db']
// Thin adapters preserving the former matter service signatures.
function confirmUpload(db: TestDbForRepo, id: string, orgId: string, opts: ConfirmUploadOptions = {}) {
return confirmUploadUsecase(
{
matter: createMatterRepo(db),
quota: createQuotaRepo(db),
storageUsage: createStorageUsageRepo(db),
activity: createActivityRepo(db),
},
id,
orgId,
opts,
)
}
function listTrashedRoots(db: TestDbForRepo, orgId: string) {
return createMatterRepo(db).listTrashedRoots(orgId)
}
function updateMatter(db: TestDbForRepo, id: string, orgId: string, input: UpdateMatterInput, userId?: string) {
return createMatterRepo(db).update(id, orgId, input, userId)
}
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -86,7 +112,10 @@ describe('reserveStorageUsage', () => {
const orgId = nanoid()
const storageId = await insertStorage(db, { id: 'st-ul', used: 0 })
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 500 })
const result = await reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 500 },
)
expect(result).toEqual({ orgId, storageId, bytes: 500 })
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
@@ -99,7 +128,10 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-q0', used: 100 })
await insertOrgQuota(db, orgId, 0, 5000)
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 999999 })
const result = await reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 999999 },
)
expect(result).toEqual({ orgId, storageId, bytes: 999999 })
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
@@ -111,8 +143,8 @@ describe('reserveStorageUsage', () => {
const orgId = nanoid()
await insertOrgQuota(db, orgId, 0, 5000)
await expect(hasQuotaForBytes(db, orgId, 10_000_000)).resolves.toBe(true)
await expect(getEffectiveQuota(db, orgId)).resolves.toMatchObject({ baseQuota: 0, quota: 0 })
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 10_000_000)).resolves.toBe(true)
await expect(createQuotaRepo(db).getEffectiveQuota(orgId)).resolves.toMatchObject({ baseQuota: 0, quota: 0 })
})
it('reserves when used + bytes is within quota', async () => {
@@ -121,7 +153,10 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-in', used: 0 })
await insertOrgQuota(db, orgId, 1000, 400)
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 500 })
const result = await reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 500 },
)
expect(result).toEqual({ orgId, storageId, bytes: 500 })
})
@@ -132,7 +167,10 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-exact', used: 0 })
await insertOrgQuota(db, orgId, 1000, 500)
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 500 })
const result = await reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 500 },
)
expect(result).toEqual({ orgId, storageId, bytes: 500 })
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
@@ -145,7 +183,12 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-over', used: 50 })
await insertOrgQuota(db, orgId, 1000, 800)
await expect(reserveStorageUsage(db, { orgId, storageId, bytes: 201 })).rejects.toThrow(StorageQuotaExceededError)
await expect(
reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 201 },
),
).rejects.toThrow(StorageQuotaExceededError)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
expect(storageRows[0].used).toBe(50) // unchanged
@@ -159,7 +202,12 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-grant', used: 0 })
await insertOrgQuota(db, orgId, 1000, 800)
await expect(reserveStorageUsage(db, { orgId, storageId, bytes: 600 })).rejects.toThrow(StorageQuotaExceededError)
await expect(
reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 600 },
),
).rejects.toThrow(StorageQuotaExceededError)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(quotaRows[0].used).toBe(800)
@@ -171,7 +219,12 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-full', used: 100 })
await insertOrgQuota(db, orgId, 1000, 1000)
await expect(reserveStorageUsage(db, { orgId, storageId, bytes: 1 })).rejects.toThrow(StorageQuotaExceededError)
await expect(
reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 1 },
),
).rejects.toThrow(StorageQuotaExceededError)
})
it('increments orgQuotas.used when within quota', async () => {
@@ -180,7 +233,10 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-q-inc', used: 0 })
await insertOrgQuota(db, orgId, 5000, 200)
await reserveStorageUsage(db, { orgId, storageId, bytes: 300 })
await reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 300 },
)
const rows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(rows[0].used).toBe(500)
@@ -192,7 +248,10 @@ describe('reserveStorageUsage', () => {
const storageId = await insertStorage(db, { id: 'st-s-inc', used: 100 })
await insertOrgQuota(db, orgId, 5000, 100)
await reserveStorageUsage(db, { orgId, storageId, bytes: 400 })
await reserveStorageUsage(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 400 },
)
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
expect(rows[0].used).toBe(500)
@@ -206,12 +265,16 @@ describe('reserveStorageUsage', () => {
const cleaned: string[] = []
await expect(
withStorageUsageReservation(db, { orgId, storageId, bytes: 300 }, async (ctx) => {
ctx.onRollback(() => {
cleaned.push('object-key')
})
throw new Error('persist failed')
}),
withStorageUsageReservation(
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
{ orgId, storageId, bytes: 300 },
async (ctx) => {
ctx.onRollback(() => {
cleaned.push('object-key')
})
throw new Error('persist failed')
},
),
).rejects.toThrow('persist failed')
expect(cleaned).toEqual(['object-key'])
@@ -244,7 +307,7 @@ describe('reserveStorageUsage', () => {
('draft-image', ${orgId}, 'ih_draft', 'draft.png', ${storageId}, 'ih/draft.png', 70, 'image/png', 'draft', 0, ${now})
`)
await reconcileStorageUsage(db, orgId, [storageId])
await createStorageUsageRepo(db).reconcile(orgId, [storageId])
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
expect(storageRows[0].used).toBe(350)
+696
View File
@@ -0,0 +1,696 @@
import { DirType, ObjectStatus } from '@shared/constants'
import type { SQL } from 'drizzle-orm'
import { and, asc, count, desc, eq, inArray, isNotNull, like, lt, ne, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { matters } from '../../db/schema'
import { suggestRenamed } from '../../domain/matter-name-conflict'
import type { Database } from '../../platform/interface'
import type {
ConflictPlan,
ConflictResolveOptions,
ConflictStrategy,
CopyMatterOptions,
CreateMatterInput,
Matter,
MatterListFilters,
MatterListResult,
MatterRepo,
UpdateMatterInput,
} from '../../usecases/ports'
import { NameConflictError } from '../../usecases/ports'
import { createActivityRepo } from './activity'
type MatterRow = typeof matters.$inferSelect
function toMatter(row: MatterRow): Matter {
return row
}
function buildPath(parent: string, name: string): string {
return parent ? `${parent}/${name}` : name
}
function descendantParentCondition(folderPath: string): SQL {
const prefix = `${folderPath}/`
return sql`SUBSTR(${matters.parent}, 1, LENGTH(${prefix})) = ${prefix}`
}
function typeFilterCondition(typeFilter: string): SQL | undefined {
switch (typeFilter) {
case 'photos':
return like(matters.type, 'image/%')
case 'videos':
return like(matters.type, 'video/%')
case 'music':
return like(matters.type, 'audio/%')
case 'documents':
return or(
like(matters.type, 'application/pdf'),
like(matters.type, 'application/msword'),
like(matters.type, 'application/vnd.%'),
like(matters.type, 'text/%'),
)
default:
return undefined
}
}
export function createMatterRepo(db: Database): MatterRepo {
const activity = createActivityRepo(db)
async function getMatter(id: string, orgId: string): Promise<Matter | null> {
const rows = await db
.select()
.from(matters)
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
return rows[0] ? toMatter(rows[0]) : null
}
function getDescendants(orgId: string, folderPath: string): Promise<MatterRow[]> {
return db
.select()
.from(matters)
.where(and(eq(matters.orgId, orgId), descendantParentCondition(folderPath)))
}
function getDirectChildren(orgId: string, folderPath: string): Promise<MatterRow[]> {
return db
.select()
.from(matters)
.where(and(eq(matters.orgId, orgId), eq(matters.parent, folderPath)))
}
async function cascadeParentPath(orgId: string, oldPath: string, newPath: string): Promise<void> {
// Direct children: parent = oldPath → parent = newPath
await db
.update(matters)
.set({ parent: newPath, updatedAt: new Date() })
.where(and(eq(matters.orgId, orgId), eq(matters.parent, oldPath)))
// Deeper descendants: parent starts with 'oldPath/' → replace prefix.
await db
.update(matters)
.set({
parent: sql`${newPath} || SUBSTR(${matters.parent}, LENGTH(${oldPath}) + 1)`,
updatedAt: new Date(),
})
.where(and(eq(matters.orgId, orgId), descendantParentCondition(oldPath)))
}
/**
* Finds the active sibling that would collide with `name` under `parent`.
* Matching is case-insensitive (mirrors the DB's partial unique index on
* LOWER(name)). `excludeId` lets rename/move skip the row being modified.
*/
async function findActiveConflict(
orgId: string,
parent: string,
name: string,
excludeId?: string,
): Promise<Matter | null> {
const conditions = [
eq(matters.orgId, orgId),
eq(matters.parent, parent),
eq(matters.status, ObjectStatus.ACTIVE),
sql`lower(${matters.name}) = lower(${name})`,
]
if (excludeId) conditions.push(ne(matters.id, excludeId))
const rows = await db
.select()
.from(matters)
.where(and(...conditions))
.limit(1)
return rows[0] ? toMatter(rows[0]) : null
}
async function findAvailableName(
orgId: string,
parent: string,
name: string,
excludeId: string | undefined,
): Promise<string> {
for (let i = 1; i <= 999; i++) {
const candidate = suggestRenamed(name, i)
const conflict = await findActiveConflict(orgId, parent, candidate, excludeId)
if (!conflict) return candidate
}
throw new Error('Too many name conflicts to auto-rename')
}
/**
* Build a resolution plan WITHOUT side effects. Safe to call and discard.
*
* Throws NameConflictError when strategy='fail' or when 'replace' is rejected
* because the incoming or existing row is a folder (not supported in v1).
*/
async function planConflictResolution(
orgId: string,
parent: string,
name: string,
strategy: ConflictStrategy,
options: ConflictResolveOptions = {},
): Promise<ConflictPlan> {
const existing = await findActiveConflict(orgId, parent, name, options.excludeId)
if (!existing) return { finalName: name, toTrash: null }
if (strategy === 'fail') {
throw new NameConflictError(existing.name, existing.id)
}
if (strategy === 'replace') {
const incomingIsFolder = options.isFolder === true
const existingIsFolder = existing.dirtype !== DirType.FILE
if (incomingIsFolder || existingIsFolder) {
throw new NameConflictError(existing.name, existing.id)
}
return { finalName: name, toTrash: existing }
}
const renamed = await findAvailableName(orgId, parent, name, options.excludeId)
return { finalName: renamed, toTrash: null }
}
async function trashForReplace(orgId: string, existing: Matter, userId: string | undefined): Promise<void> {
const now = new Date()
await db
.update(matters)
.set({ status: ObjectStatus.TRASHED, trashedAt: now.getTime(), updatedAt: now })
.where(and(eq(matters.id, existing.id), eq(matters.orgId, orgId)))
if (userId) {
await activity.record({
orgId,
userId,
action: 'replace',
targetType: 'file',
targetId: existing.id,
targetName: existing.name,
})
}
}
/** Execute the side effects of a plan (trash the replaced row, log activity). */
async function commitConflictPlan(orgId: string, plan: ConflictPlan, userId?: string): Promise<void> {
if (plan.toTrash) {
await trashForReplace(orgId, plan.toTrash, userId)
}
}
/**
* Convenience for the common case: plan + commit back-to-back. Callers that
* need to interleave checks (e.g. quota) between plan and commit should use
* planConflictResolution / commitConflictPlan directly — see confirmUpload.
*/
async function applyConflictResolution(
orgId: string,
parent: string,
name: string,
strategy: ConflictStrategy,
options: ConflictResolveOptions = {},
): Promise<string> {
const plan = await planConflictResolution(orgId, parent, name, strategy, options)
await commitConflictPlan(orgId, plan, options.userId)
return plan.finalName
}
function collectForPurge(orgId: string, idOrMatter: string): Promise<Matter[] | null>
function collectForPurge(orgId: string, idOrMatter: Matter): Promise<Matter[]>
async function collectForPurge(orgId: string, idOrMatter: string | Matter): Promise<Matter[] | null> {
const existing = typeof idOrMatter === 'string' ? await getMatter(idOrMatter, orgId) : idOrMatter
if (!existing) return null
if (existing.dirtype === DirType.FILE) return [existing]
const path = buildPath(existing.parent, existing.name)
const children = await getDirectChildren(orgId, path)
const descendants = await getDescendants(orgId, path)
return [existing, ...children.map(toMatter), ...descendants.map(toMatter)]
}
const repo: MatterRepo = {
async create(input: CreateMatterInput): Promise<Matter> {
const now = new Date()
const isFolder = (input.dirtype ?? 0) !== DirType.FILE
const parent = input.parent ?? ''
// Resolve name collisions against existing active siblings BEFORE inserting —
// for folders this prevents duplicates at creation; for files it catches the
// conflict before the client wastes a large S3 upload.
const plan = await planConflictResolution(input.orgId, parent, input.name, input.onConflict ?? 'fail', {
isFolder,
userId: input.userId,
})
// Overwriting the incumbent for a draft-file 'replace' is deferred to
// confirmUpload (which purges it after the new bytes land). That keeps a
// failed/abandoned upload from destroying the existing file, and lets the
// replace be charged as a net-size change. Folders, active creates, and
// rename commit their plan immediately.
const deferOverwrite = !isFolder && input.status === 'draft' && plan.toTrash !== null
if (!deferOverwrite) {
await commitConflictPlan(input.orgId, plan, input.userId)
}
const finalName = plan.finalName
const row: MatterRow = {
id: nanoid(),
orgId: input.orgId,
alias: nanoid(10),
name: finalName,
type: input.type,
size: input.size ?? 0,
dirtype: input.dirtype ?? 0,
parent,
object: input.object,
storageId: input.storageId,
status: input.status,
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(row)
if (input.userId) {
await activity.record({
orgId: input.orgId,
userId: input.userId,
action: isFolder ? 'create' : 'upload',
targetType: isFolder ? 'folder' : 'file',
targetId: row.id,
targetName: row.name,
})
}
return toMatter(row)
},
async list(orgId: string, filters: MatterListFilters): Promise<MatterListResult> {
const offset = (filters.page - 1) * filters.pageSize
if (filters.status === 'trashed' && !filters.search && !filters.typeFilter) {
const roots = await repo.listTrashedRoots(orgId)
return {
items: roots.slice(offset, offset + filters.pageSize),
total: roots.length,
page: filters.page,
pageSize: filters.pageSize,
}
}
const conditions = [eq(matters.orgId, orgId), eq(matters.status, filters.status)]
const typeCond = filters.typeFilter ? typeFilterCondition(filters.typeFilter) : undefined
if (filters.search) {
conditions.push(like(matters.name, `%${filters.search}%`))
} else if (typeCond) {
conditions.push(typeCond)
conditions.push(eq(matters.dirtype, DirType.FILE))
} else {
conditions.push(eq(matters.parent, filters.parent ?? ''))
}
const where = and(...conditions)
const countRows = await db.select({ count: count() }).from(matters).where(where)
const total = countRows[0]?.count ?? 0
const items = await db
.select()
.from(matters)
.where(where)
.orderBy(desc(matters.dirtype), asc(matters.createdAt))
.limit(filters.pageSize)
.offset(offset)
return { items: items.map(toMatter), total, page: filters.page, pageSize: filters.pageSize }
},
get(id, orgId) {
return getMatter(id, orgId)
},
async getMany(orgId, ids) {
if (ids.length === 0) return []
const rows = await db
.select()
.from(matters)
.where(and(eq(matters.orgId, orgId), inArray(matters.id, ids)))
return rows.map(toMatter)
},
async update(id, orgId, input: UpdateMatterInput, userId?: string): Promise<Matter | null> {
const existing = await getMatter(id, orgId)
if (!existing) return null
const now = new Date()
const requestedName = input.name ?? existing.name
const newParent = input.parent ?? existing.parent
const isFolder = existing.dirtype !== DirType.FILE
const renamed = input.name && input.name !== existing.name
const moved = input.parent !== undefined && input.parent !== existing.parent
// Guard: reject before touching descendants. Must happen after rename/move
// detection but before cascading path updates.
if (isFolder && (renamed || moved)) {
const oldPath = buildPath(existing.parent, existing.name)
if (newParent === oldPath || newParent.startsWith(`${oldPath}/`)) {
throw new Error('Cannot move a folder into itself or its subfolder')
}
}
// Resolve name conflict in the destination parent. Excludes self so a no-op
// rename (A → A) is allowed.
const newName =
renamed || moved
? await applyConflictResolution(orgId, newParent, requestedName, input.onConflict ?? 'fail', {
excludeId: existing.id,
isFolder,
userId,
})
: requestedName
if (isFolder && (renamed || moved)) {
const oldPath = buildPath(existing.parent, existing.name)
const newPath = buildPath(newParent, newName)
await cascadeParentPath(orgId, oldPath, newPath)
}
await db
.update(matters)
.set({ name: newName, parent: newParent, updatedAt: now })
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
const updated = { ...existing, name: newName, parent: newParent, updatedAt: now }
if (userId) {
const targetType = isFolder ? 'folder' : 'file'
// Compare the final persisted state so auto-renames (from conflict resolution)
// are recorded even when the user only asked to move.
if (newName !== existing.name) {
await activity.record({
orgId,
userId,
action: 'rename',
targetType,
targetId: id,
targetName: newName,
metadata: { from: existing.name },
})
}
if (newParent !== existing.parent) {
await activity.record({
orgId,
userId,
action: 'move',
targetType,
targetId: id,
targetName: newName,
metadata: { from: existing.parent, to: newParent },
})
}
}
return updated
},
async copy(source, targetParent, newObject, opts: CopyMatterOptions = {}): Promise<Matter> {
const now = new Date()
const isFolder = source.dirtype !== DirType.FILE
// Default to 'rename' for copy — copying "foo.pdf" into the same folder almost
// always means "make a duplicate", so the Finder-style auto-rename is the
// intuitive default when the caller didn't pick a strategy.
const finalName = await applyConflictResolution(
source.orgId,
targetParent,
source.name,
opts.onConflict ?? 'rename',
{
isFolder,
userId: opts.userId,
},
)
const row: MatterRow = {
id: nanoid(),
orgId: source.orgId,
alias: nanoid(10),
name: finalName,
type: source.type,
size: source.size,
dirtype: source.dirtype,
parent: targetParent,
object: newObject,
storageId: source.storageId,
status: 'active',
trashedAt: null,
createdAt: now,
updatedAt: now,
}
await db.insert(matters).values(row)
if (opts.userId) {
await activity.record({
orgId: source.orgId,
userId: opts.userId,
action: 'object_copy',
targetType: isFolder ? 'folder' : 'file',
targetId: row.id,
targetName: row.name,
metadata: { from: source.name, to: targetParent },
})
}
return toMatter(row)
},
async delete(id, orgId): Promise<Matter | null> {
const existing = await getMatter(id, orgId)
if (!existing) return null
await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
return existing
},
async cancelDraft(id, orgId, userId?): Promise<Matter | null> {
const existing = await getMatter(id, orgId)
if (!existing || existing.status !== 'draft') return null
await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId), eq(matters.status, 'draft')))
if (userId) {
await activity.record({
orgId,
userId,
action: 'upload_cancel',
targetType: 'file',
targetId: existing.id,
targetName: existing.name,
})
}
return existing
},
async trash(orgId, id, userId?): Promise<Matter | null> {
const existing = await getMatter(id, orgId)
if (!existing) return null
if (existing.status === 'trashed') return existing
const now = new Date()
const nowTs = now.getTime()
const allIds = [existing.id]
if (existing.dirtype !== DirType.FILE) {
const path = buildPath(existing.parent, existing.name)
const children = await getDirectChildren(orgId, path)
const descendants = await getDescendants(orgId, path)
allIds.push(...children.map((m) => m.id), ...descendants.map((m) => m.id))
}
for (const targetId of allIds) {
await db
.update(matters)
.set({ status: 'trashed', trashedAt: nowTs, updatedAt: now })
.where(and(eq(matters.id, targetId), eq(matters.orgId, orgId), eq(matters.status, 'active')))
}
const trashed = { ...existing, status: 'trashed', trashedAt: nowTs, updatedAt: now }
if (userId) {
await activity.record({
orgId,
userId,
action: 'delete',
targetType: existing.dirtype !== DirType.FILE ? 'folder' : 'file',
targetId: existing.id,
targetName: existing.name,
})
}
return trashed
},
async restore(orgId, id, userId?, onConflict: ConflictStrategy = 'fail'): Promise<Matter | null> {
const existing = await getMatter(id, orgId)
if (!existing) return null
if (existing.status !== 'trashed') return existing
// A same-named active item may have been created in the original parent
// while this one sat in trash. Resolve before touching descendants so a
// rejection doesn't leave folders half-restored.
const isFolder = existing.dirtype !== DirType.FILE
const finalName = await applyConflictResolution(orgId, existing.parent, existing.name, onConflict, {
excludeId: existing.id,
isFolder,
userId,
})
const now = new Date()
const allIds = [existing.id]
if (isFolder) {
const path = buildPath(existing.parent, existing.name)
const children = await getDirectChildren(orgId, path)
const descendants = await getDescendants(orgId, path)
allIds.push(...children.map((m) => m.id), ...descendants.map((m) => m.id))
}
// Rename + cascade parent paths BEFORE activation. While everything is still
// trashed, these writes cannot violate the active-name unique index, and no
// reader ever sees descendants with stale paths in an active state.
if (finalName !== existing.name) {
await db
.update(matters)
.set({ name: finalName, updatedAt: now })
.where(and(eq(matters.id, existing.id), eq(matters.orgId, orgId)))
if (isFolder) {
const oldPath = buildPath(existing.parent, existing.name)
const newPath = buildPath(existing.parent, finalName)
await cascadeParentPath(orgId, oldPath, newPath)
}
}
for (const targetId of allIds) {
await db
.update(matters)
.set({ status: 'active', trashedAt: null, updatedAt: now })
.where(and(eq(matters.id, targetId), eq(matters.orgId, orgId), eq(matters.status, 'trashed')))
}
const restored = { ...existing, name: finalName, status: 'active', trashedAt: null, updatedAt: now }
if (userId) {
await activity.record({
orgId,
userId,
action: 'restore',
targetType: isFolder ? 'folder' : 'file',
targetId: existing.id,
targetName: finalName,
})
}
return restored
},
collectForPurge,
async purge(orgId, ids): Promise<void> {
for (const id of ids) {
await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
}
},
async listActiveDescendants(orgId, parentPath): Promise<Matter[]> {
// Exact-prefix match (SUBSTR), not LIKE — folder names can contain `_`/`%`,
// which LIKE would treat as wildcards and over-match. Matches the rest of this repo.
const rows = await db
.select()
.from(matters)
.where(
and(eq(matters.orgId, orgId), eq(matters.status, ObjectStatus.ACTIVE), descendantParentCondition(parentPath)),
)
return rows.map(toMatter)
},
async trashByIds(orgId, ids): Promise<void> {
if (ids.length === 0) return
await db
.update(matters)
.set({ status: ObjectStatus.TRASHED, trashedAt: Date.now(), updatedAt: new Date() })
.where(and(eq(matters.orgId, orgId), inArray(matters.id, ids)))
},
async restoreActiveByIds(orgId, ids): Promise<void> {
if (ids.length === 0) return
await db
.update(matters)
.set({ status: ObjectStatus.ACTIVE, trashedAt: null, updatedAt: new Date() })
.where(and(eq(matters.orgId, orgId), inArray(matters.id, ids)))
},
async touch(orgId, id): Promise<void> {
await db
.update(matters)
.set({ updatedAt: new Date() })
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
},
async applyUpload(orgId, id, fields): Promise<void> {
await db
.update(matters)
.set({ type: fields.type, size: fields.size, object: fields.object, updatedAt: new Date() })
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
},
async listTrashedRoots(orgId): Promise<Matter[]> {
const all = await db
.select()
.from(matters)
.where(and(eq(matters.orgId, orgId), eq(matters.status, 'trashed')))
const trashedPaths = new Set(all.map((m) => buildPath(m.parent, m.name)))
return all
.filter((m) => !trashedPaths.has(m.parent))
.sort((a, b) => {
const aTrashedAt = a.trashedAt ?? 0
const bTrashedAt = b.trashedAt ?? 0
if (aTrashedAt !== bTrashedAt) return bTrashedAt - aTrashedAt
return b.createdAt.getTime() - a.createdAt.getTime()
})
.map(toMatter)
},
async listOrgIdsWithExpiredTrash(cutoff): Promise<string[]> {
const rows = await db
.selectDistinct({ orgId: matters.orgId })
.from(matters)
.where(and(eq(matters.status, 'trashed'), isNotNull(matters.trashedAt), lt(matters.trashedAt, cutoff)))
return rows.map((r) => r.orgId)
},
findActiveConflict(orgId, parent, name, excludeId) {
return findActiveConflict(orgId, parent, name, excludeId)
},
planConflictResolution(orgId, parent, name, strategy, options) {
return planConflictResolution(orgId, parent, name, strategy, options)
},
commitConflictPlan(orgId, plan, userId) {
return commitConflictPlan(orgId, plan, userId)
},
applyConflictResolution(orgId, parent, name, strategy, options) {
return applyConflictResolution(orgId, parent, name, strategy, options)
},
async activateDraft(id, orgId, finalName, now): Promise<boolean> {
const updated = await db
.update(matters)
.set({ name: finalName, status: 'active', updatedAt: now })
.where(and(eq(matters.id, id), eq(matters.orgId, orgId), eq(matters.status, 'draft')))
.returning({ id: matters.id })
return updated.length > 0
},
}
return repo
}
+13
View File
@@ -0,0 +1,13 @@
import { eq } from 'drizzle-orm'
import { member } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import type { MemberCountRepo } from '../../usecases/ports'
export function createMemberCountRepo(db: Database): MemberCountRepo {
return {
async countUserOrgs(userId) {
const rows = await db.select({ id: member.id }).from(member).where(eq(member.userId, userId))
return rows.length
},
}
}
@@ -1,14 +1,8 @@
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import {
createNotification,
listNotifications,
markAllAsRead,
markAsRead,
unreadCount,
} from '../services/notification.js'
import { createTestApp } from '../test/setup.js'
import * as authSchema from '../../db/auth-schema.js'
import { createTestApp } from '../../test/setup.js'
import { createNotificationRepo } from './notification.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -30,7 +24,7 @@ describe('createNotification', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n = await createNotification(db, { userId, type: 'share_received', title: 'You got a share' })
const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'You got a share' })
expect(n.id).toBeDefined()
expect(n.userId).toBe(userId)
@@ -45,7 +39,7 @@ describe('createNotification', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n = await createNotification(db, {
const n = await createNotificationRepo(db).create({
userId,
type: 'share_received',
title: 'Test',
@@ -67,7 +61,7 @@ describe('listNotifications', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
const result = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 20 })
expect(result.items).toHaveLength(0)
expect(result.total).toBe(0)
@@ -79,14 +73,14 @@ describe('listNotifications', () => {
const userId = await insertUser(db)
for (let i = 0; i < 5; i++) {
await createNotification(db, { userId, type: 'share_received', title: `Notification ${i}` })
await createNotificationRepo(db).create({ userId, type: 'share_received', title: `Notification ${i}` })
}
const page1 = await listNotifications(db, userId, { page: 1, pageSize: 3 })
const page1 = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 3 })
expect(page1.items).toHaveLength(3)
expect(page1.total).toBe(5)
const page2 = await listNotifications(db, userId, { page: 2, pageSize: 3 })
const page2 = await createNotificationRepo(db).list(userId, { page: 2, pageSize: 3 })
expect(page2.items).toHaveLength(2)
})
@@ -94,11 +88,11 @@ describe('listNotifications', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
await markAsRead(db, userId, n1.id)
const n1 = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
await createNotificationRepo(db).markAsRead(userId, n1.id)
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
const result = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 20 })
expect(result.total).toBe(2)
expect(result.unreadCount).toBe(1)
})
@@ -107,11 +101,11 @@ describe('listNotifications', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
await markAsRead(db, userId, n1.id)
const n1 = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
await createNotificationRepo(db).markAsRead(userId, n1.id)
const result = await listNotifications(db, userId, { page: 1, pageSize: 20, unreadOnly: true })
const result = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 20, unreadOnly: true })
expect(result.items).toHaveLength(1)
expect(result.items[0].title).toBe('B')
})
@@ -121,9 +115,9 @@ describe('listNotifications', () => {
const user1 = await insertUser(db)
const user2 = await insertUser(db)
await createNotification(db, { userId: user1, type: 'share_received', title: 'For user1' })
await createNotificationRepo(db).create({ userId: user1, type: 'share_received', title: 'For user1' })
const result = await listNotifications(db, user2, { page: 1, pageSize: 20 })
const result = await createNotificationRepo(db).list(user2, { page: 1, pageSize: 20 })
expect(result.items).toHaveLength(0)
})
})
@@ -132,15 +126,15 @@ describe('markAsRead', () => {
it('marks a notification as read (idempotent)', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' })
const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'Test' })
const first = await markAsRead(db, userId, n.id)
const first = await createNotificationRepo(db).markAsRead(userId, n.id)
expect(first).toBe(true)
const second = await markAsRead(db, userId, n.id)
const second = await createNotificationRepo(db).markAsRead(userId, n.id)
expect(second).toBe(true)
const count = await unreadCount(db, userId)
const count = await createNotificationRepo(db).unreadCount(userId)
expect(count).toBe(0)
})
@@ -148,12 +142,12 @@ describe('markAsRead', () => {
const { db } = await createTestApp()
const owner = await insertUser(db)
const other = await insertUser(db)
const n = await createNotification(db, { userId: owner, type: 'share_received', title: 'Test' })
const n = await createNotificationRepo(db).create({ userId: owner, type: 'share_received', title: 'Test' })
const result = await markAsRead(db, other, n.id)
const result = await createNotificationRepo(db).markAsRead(other, n.id)
expect(result).toBe(false)
const count = await unreadCount(db, owner)
const count = await createNotificationRepo(db).unreadCount(owner)
expect(count).toBe(1)
})
})
@@ -163,13 +157,13 @@ describe('markAllAsRead', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
const result = await markAllAsRead(db, userId)
const result = await createNotificationRepo(db).markAllAsRead(userId)
expect(result.count).toBe(2)
const count = await unreadCount(db, userId)
const count = await createNotificationRepo(db).unreadCount(userId)
expect(count).toBe(0)
})
@@ -178,20 +172,20 @@ describe('markAllAsRead', () => {
const user1 = await insertUser(db)
const user2 = await insertUser(db)
await createNotification(db, { userId: user1, type: 'share_received', title: 'A' })
await createNotification(db, { userId: user2, type: 'share_received', title: 'B' })
await createNotificationRepo(db).create({ userId: user1, type: 'share_received', title: 'A' })
await createNotificationRepo(db).create({ userId: user2, type: 'share_received', title: 'B' })
await markAllAsRead(db, user1)
await createNotificationRepo(db).markAllAsRead(user1)
expect(await unreadCount(db, user1)).toBe(0)
expect(await unreadCount(db, user2)).toBe(1)
expect(await createNotificationRepo(db).unreadCount(user1)).toBe(0)
expect(await createNotificationRepo(db).unreadCount(user2)).toBe(1)
})
it('returns 0 when nothing to mark', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await markAllAsRead(db, userId)
const result = await createNotificationRepo(db).markAllAsRead(userId)
expect(result.count).toBe(0)
})
})
@@ -201,14 +195,14 @@ describe('unreadCount', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
expect(await unreadCount(db, userId)).toBe(0)
expect(await createNotificationRepo(db).unreadCount(userId)).toBe(0)
const n = await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
expect(await unreadCount(db, userId)).toBe(2)
expect(await createNotificationRepo(db).unreadCount(userId)).toBe(2)
await markAsRead(db, userId, n.id)
expect(await unreadCount(db, userId)).toBe(1)
await createNotificationRepo(db).markAsRead(userId, n.id)
expect(await createNotificationRepo(db).unreadCount(userId)).toBe(1)
})
})
+99
View File
@@ -0,0 +1,99 @@
import { and, count, desc, eq, isNull } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { notifications } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { NotificationRecord, NotificationRepo } from '../../usecases/ports'
type NotificationRow = typeof notifications.$inferSelect
function toRecord(row: NotificationRow): NotificationRecord {
return row as NotificationRecord
}
export function createNotificationRepo(db: Database): NotificationRepo {
return {
async create(input) {
const row: NotificationRow = {
id: nanoid(),
userId: input.userId,
type: input.type,
title: input.title,
body: input.body ?? '',
refType: input.refType ?? null,
refId: input.refId ?? null,
metadata: input.metadata ?? null,
readAt: null,
createdAt: new Date(),
}
await db.insert(notifications).values(row)
return toRecord(row)
},
async list(userId, opts) {
const { page, pageSize, unreadOnly } = opts
const offset = (page - 1) * pageSize
const baseCondition = unreadOnly
? and(eq(notifications.userId, userId), isNull(notifications.readAt))
: eq(notifications.userId, userId)
const [items, totalRows, unreadRows] = await Promise.all([
db
.select()
.from(notifications)
.where(baseCondition)
.orderBy(desc(notifications.createdAt))
.limit(pageSize)
.offset(offset),
db.select({ count: count() }).from(notifications).where(baseCondition),
db
.select({ count: count() })
.from(notifications)
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt))),
])
return {
items: items.map(toRecord),
total: totalRows[0]?.count ?? 0,
unreadCount: unreadRows[0]?.count ?? 0,
}
},
async markAsRead(userId, id) {
const rows = await db
.select({ id: notifications.id, readAt: notifications.readAt })
.from(notifications)
.where(and(eq(notifications.id, id), eq(notifications.userId, userId)))
.limit(1)
if (!rows[0]) return false
if (!rows[0].readAt) {
await db.update(notifications).set({ readAt: new Date() }).where(eq(notifications.id, id))
}
return true
},
async markAllAsRead(userId) {
const unread = await db
.select({ id: notifications.id })
.from(notifications)
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
if (unread.length === 0) return { count: 0 }
await db
.update(notifications)
.set({ readAt: new Date() })
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
return { count: unread.length }
},
async unreadCount(userId) {
const rows = await db
.select({ count: count() })
.from(notifications)
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
return rows[0]?.count ?? 0
},
}
}
@@ -0,0 +1,70 @@
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { objectUploadSessions } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { ObjectUploadSessionRecord, ObjectUploadSessionRepo } from '../../usecases/ports'
const SESSION_TTL_MS = 24 * 60 * 60 * 1000
type SessionRow = typeof objectUploadSessions.$inferSelect
function toRecord(row: SessionRow): ObjectUploadSessionRecord {
return {
id: row.id,
objectId: row.objectId,
uploadId: row.uploadId,
partSize: row.partSize,
status: row.status as ObjectUploadSessionRecord['status'],
storageKey: row.storageKey,
expiresAt: row.expiresAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export function createObjectUploadSessionRepo(db: Database): ObjectUploadSessionRepo {
return {
async create(input) {
const now = new Date()
const row: typeof objectUploadSessions.$inferInsert = {
id: nanoid(),
orgId: input.orgId,
objectId: input.objectId,
storageId: input.storageId,
storageKey: input.storageKey,
uploadId: input.uploadId,
partSize: input.partSize,
status: 'active',
createdBy: input.actorId,
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
createdAt: now,
updatedAt: now,
}
await db.insert(objectUploadSessions).values(row)
return toRecord(row as SessionRow)
},
async get(orgId, objectId, id) {
const rows = await db
.select()
.from(objectUploadSessions)
.where(
and(
eq(objectUploadSessions.id, id),
eq(objectUploadSessions.orgId, orgId),
eq(objectUploadSessions.objectId, objectId),
),
)
.limit(1)
const row = rows[0]
return row ? toRecord(row) : null
},
async setStatus(id, status) {
await db
.update(objectUploadSessions)
.set({ status, updatedAt: new Date() })
.where(eq(objectUploadSessions.id, id))
},
}
}
@@ -1,8 +1,8 @@
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { createTestApp } from '../test/setup.js'
import { findPersonalOrg } from './org.js'
import * as authSchema from '../../db/auth-schema.js'
import { createTestApp } from '../../test/setup.js'
import { createOrgRepo } from './org.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -48,7 +48,7 @@ describe('findPersonalOrg', () => {
const orgId = await insertOrg(db, { slug: `personal-${userId}` })
await insertMember(db, orgId, userId)
const result = await findPersonalOrg(db, userId)
const result = await createOrgRepo(db).findPersonalOrg(userId)
expect(result).toBe(orgId)
})
@@ -56,7 +56,7 @@ describe('findPersonalOrg', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await findPersonalOrg(db, userId)
const result = await createOrgRepo(db).findPersonalOrg(userId)
expect(result).toBeNull()
})
@@ -66,7 +66,7 @@ describe('findPersonalOrg', () => {
const orgId = await insertOrg(db, { slug: 'some-team-org' })
await insertMember(db, orgId, userId)
const result = await findPersonalOrg(db, userId)
const result = await createOrgRepo(db).findPersonalOrg(userId)
expect(result).toBeNull()
})
@@ -78,7 +78,7 @@ describe('findPersonalOrg', () => {
await insertMember(db, teamOrgId, userId)
await insertMember(db, personalOrgId, userId)
const result = await findPersonalOrg(db, userId)
const result = await createOrgRepo(db).findPersonalOrg(userId)
expect(result).toBe(personalOrgId)
})
@@ -88,7 +88,7 @@ describe('findPersonalOrg', () => {
await insertOrg(db, { slug: `personal-${userId}` })
// No member row inserted — membership is load-bearing
const result = await findPersonalOrg(db, userId)
const result = await createOrgRepo(db).findPersonalOrg(userId)
expect(result).toBeNull()
})
})
@@ -1,8 +1,8 @@
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { createTestApp } from '../test/setup.js'
import { getMemberRole, isPersonalOrg } from './org.js'
import * as authSchema from '../../db/auth-schema.js'
import { createTestApp } from '../../test/setup.js'
import { createOrgRepo } from './org.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -47,7 +47,7 @@ describe('getMemberRole', () => {
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'owner')
const result = await getMemberRole(db, orgId, userId)
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
expect(result).toBe('owner')
})
@@ -57,7 +57,7 @@ describe('getMemberRole', () => {
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'editor')
const result = await getMemberRole(db, orgId, userId)
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
expect(result).toBe('editor')
})
@@ -67,7 +67,7 @@ describe('getMemberRole', () => {
const orgId = await insertOrg(db)
await insertMember(db, orgId, userId, 'viewer')
const result = await getMemberRole(db, orgId, userId)
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
expect(result).toBe('viewer')
})
@@ -76,7 +76,7 @@ describe('getMemberRole', () => {
const userId = await insertUser(db)
const orgId = await insertOrg(db)
const result = await getMemberRole(db, orgId, userId)
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
expect(result).toBeNull()
})
@@ -84,7 +84,7 @@ describe('getMemberRole', () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await getMemberRole(db, 'nonexistent-org', userId)
const result = await createOrgRepo(db).getMemberRole('nonexistent-org', userId)
expect(result).toBeNull()
})
@@ -92,7 +92,7 @@ describe('getMemberRole', () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const result = await getMemberRole(db, orgId, 'nonexistent-user')
const result = await createOrgRepo(db).getMemberRole(orgId, 'nonexistent-user')
expect(result).toBeNull()
})
@@ -104,8 +104,8 @@ describe('getMemberRole', () => {
await insertMember(db, orgA, userId, 'editor')
await insertMember(db, orgB, userId, 'viewer')
expect(await getMemberRole(db, orgA, userId)).toBe('editor')
expect(await getMemberRole(db, orgB, userId)).toBe('viewer')
expect(await createOrgRepo(db).getMemberRole(orgA, userId)).toBe('editor')
expect(await createOrgRepo(db).getMemberRole(orgB, userId)).toBe('viewer')
})
})
@@ -115,7 +115,7 @@ describe('isPersonalOrg', () => {
const userId = nanoid()
const orgId = await insertOrg(db, { slug: `personal-${userId}` })
const result = await isPersonalOrg(db, orgId)
const result = await createOrgRepo(db).isPersonalOrg(orgId)
expect(result).toBe(true)
})
@@ -123,7 +123,7 @@ describe('isPersonalOrg', () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db, { slug: 'team-org-slug' })
const result = await isPersonalOrg(db, orgId)
const result = await createOrgRepo(db).isPersonalOrg(orgId)
expect(result).toBe(false)
})
@@ -131,14 +131,14 @@ describe('isPersonalOrg', () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db, { slug: 'personal' })
const result = await isPersonalOrg(db, orgId)
const result = await createOrgRepo(db).isPersonalOrg(orgId)
expect(result).toBe(false)
})
it('returns false when the org does not exist', async () => {
const { db } = await createTestApp()
const result = await isPersonalOrg(db, 'nonexistent-org-id')
const result = await createOrgRepo(db).isPersonalOrg('nonexistent-org-id')
expect(result).toBe(false)
})
@@ -146,7 +146,7 @@ describe('isPersonalOrg', () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db, { slug: 'team-personal-space' })
const result = await isPersonalOrg(db, orgId)
const result = await createOrgRepo(db).isPersonalOrg(orgId)
expect(result).toBe(false)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { and, eq } from 'drizzle-orm'
import { member, organization } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import type { OrgRepo } from '../../usecases/ports'
const ROLE_LEVELS: Record<string, number> = { owner: 3, editor: 2, viewer: 1, member: 1 }
export function createOrgRepo(db: Database): OrgRepo {
// Find the user's personal org, if they still belong to it. The personal org
// slug is a deterministic `personal-${user.id}`; filter on the indexed UNIQUE
// slug then verify the member row still exists (an admin can revoke access by
// deleting the member row without deleting the org).
async function findPersonalOrg(userId: string): Promise<string | null> {
const rows = await db
.select({ orgId: organization.id })
.from(organization)
.innerJoin(member, and(eq(member.organizationId, organization.id), eq(member.userId, userId)))
.where(eq(organization.slug, `personal-${userId}`))
.limit(1)
return rows[0]?.orgId ?? null
}
async function getMemberRole(orgId: string, userId: string): Promise<string | null> {
const rows = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
.limit(1)
return rows[0]?.role ?? null
}
async function isPersonalOrg(orgId: string): Promise<boolean> {
const rows = await db
.select({ slug: organization.slug })
.from(organization)
.where(eq(organization.id, orgId))
.limit(1)
return (rows[0]?.slug ?? '').startsWith('personal-')
}
// Read access: any membership role, or the user's own personal org. Never
// grants access to another user's personal org.
async function canReadOrg(userId: string, orgId: string): Promise<boolean> {
const role = await getMemberRole(orgId, userId)
if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.viewer
return orgId === (await findPersonalOrg(userId))
}
// Write access: editor/owner membership, or the user's own personal org.
// Checking ownership via findPersonalOrg (not isPersonalOrg) is load-bearing:
// a request-supplied orgId must never write into another user's personal space.
async function canWriteToOrg(userId: string, orgId: string): Promise<boolean> {
const role = await getMemberRole(orgId, userId)
if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.editor
return orgId === (await findPersonalOrg(userId))
}
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, isPersonalOrg }
}
+24
View File
@@ -0,0 +1,24 @@
import { eq } from 'drizzle-orm'
import { user } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import type { ProfileRepo } from '../../usecases/ports'
export function createProfileRepo(db: Database): ProfileRepo {
return {
async getUserByUsername(username) {
const rows = await db
.select({ username: user.username, name: user.name, image: user.image })
.from(user)
.where(eq(user.username, username))
.limit(1)
const row = rows[0]
if (!row?.username) return null
return { username: row.username, name: row.name, image: row.image ?? null }
},
async setAvatar(userId, image) {
await db.update(user).set({ image }).where(eq(user.id, userId))
},
}
}
@@ -1,18 +1,9 @@
import { eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
import { createTestApp } from '../test/setup.js'
import {
consumeTrafficIfQuotaAllows,
getEffectiveQuota,
getEffectiveQuotasByOrg,
hasQuotaForBytes,
hasTrafficQuotaForBytes,
incrementUsageIfEffectiveQuotaAllows,
refundTraffic,
resetExpiredTrafficQuotas,
} from './effective-quota.js'
import { orgQuotaEntitlements, orgQuotas } from '../../db/schema.js'
import { createTestApp } from '../../test/setup.js'
import { createQuotaRepo } from './quota.js'
describe('effective quota', () => {
it('returns storage and traffic quota state', async () => {
@@ -34,15 +25,17 @@ describe('effective quota', () => {
entitlement(orgId, 'traffic', 'free-traffic-plan', 2000, 'active', new Date('2026-05-06T00:00:00Z'), 'Free'),
])
await expect(getEffectiveQuota(db, orgId, new Date('2026-05-06T00:00:00Z'))).resolves.toMatchObject({
orgId,
baseQuota: 1000,
quota: 1000,
used: 250,
trafficQuota: 2000,
trafficUsed: 500,
trafficPeriod: '2026-05',
})
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, new Date('2026-05-06T00:00:00Z'))).resolves.toMatchObject(
{
orgId,
baseQuota: 1000,
quota: 1000,
used: 250,
trafficQuota: 2000,
trafficUsed: 500,
trafficPeriod: '2026-05',
},
)
})
it('adds active entitlement bytes to effective storage and traffic quota', async () => {
@@ -70,7 +63,7 @@ describe('effective quota', () => {
},
])
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
baseQuota: 1000,
entitlementQuota: 300,
quota: 1300,
@@ -104,7 +97,7 @@ describe('effective quota', () => {
entitlement(orgId, 'traffic', 'order-traffic-pack', 700, 'active', now),
])
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
baseQuota: 3000,
entitlementQuota: 500,
quota: 3500,
@@ -138,7 +131,7 @@ describe('effective quota', () => {
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic:${orgId}`, 4000, 'active', now),
])
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
baseQuota: 3000,
quota: 3000,
baseTrafficQuota: 4000,
@@ -170,7 +163,7 @@ describe('effective quota', () => {
entitlement(orgId, 'traffic', 'order-traffic-pack-2', 200, 'active', now, 'Burst Pack'),
])
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
entitlementQuota: 800,
quota: 3800,
entitlementTrafficQuota: 900,
@@ -217,7 +210,7 @@ describe('effective quota', () => {
},
])
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
currentPlan: {
sourceId,
packageId: 'pkg-team',
@@ -263,7 +256,7 @@ describe('effective quota', () => {
},
])
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
baseQuota: 0,
entitlementQuota: 0,
quota: 0,
@@ -286,7 +279,7 @@ describe('effective quota', () => {
trafficPeriod: '2026-04',
})
const quota = await getEffectiveQuota(db, orgId, new Date('2026-05-01T00:00:00Z'))
const quota = await createQuotaRepo(db).getEffectiveQuota(orgId, new Date('2026-05-01T00:00:00Z'))
expect(quota.trafficUsed).toBe(0)
expect(quota.trafficPeriod).toBe('2026-05')
@@ -323,7 +316,7 @@ describe('effective quota', () => {
},
])
await resetExpiredTrafficQuotas(db, new Date('2026-05-01T00:00:00Z'))
await createQuotaRepo(db).resetExpiredTrafficQuotas(new Date('2026-05-01T00:00:00Z'))
const stale = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, staleOrg))
expect(stale[0].trafficUsed).toBe(0)
@@ -363,12 +356,12 @@ describe('effective quota', () => {
entitlement(planOrg, 'traffic', 'order-traffic-pack', 700, 'active', now, 'Traffic Boost'),
])
const batch = await getEffectiveQuotasByOrg(db, [planOrg, staleOrg, emptyOrg], now)
const batch = await createQuotaRepo(db).getEffectiveQuotasByOrg([planOrg, staleOrg, emptyOrg], now)
expect(batch.size).toBe(3)
// Batch result must match the per-org function exactly.
for (const orgId of [planOrg, staleOrg, emptyOrg]) {
expect(batch.get(orgId)).toEqual(await getEffectiveQuota(db, orgId, now))
expect(batch.get(orgId)).toEqual(await createQuotaRepo(db).getEffectiveQuota(orgId, now))
}
expect(batch.get(planOrg)).toMatchObject({
@@ -412,7 +405,7 @@ describe('effective quota', () => {
.insert(orgQuotaEntitlements)
.values(entitlement(taggedOrg, 'storage', `stripe_subscription:sub:${taggedOrg}`, 3000, 'active', now, 'Team'))
const batch = await getEffectiveQuotasByOrg(db, orgIds, now)
const batch = await createQuotaRepo(db).getEffectiveQuotasByOrg(orgIds, now)
expect(batch.size).toBe(200)
expect(batch.get(orgIds[0])).toMatchObject({ used: 0, quota: 0 })
@@ -422,7 +415,9 @@ describe('effective quota', () => {
it('returns an empty map for no orgs', async () => {
const { db } = await createTestApp()
await expect(getEffectiveQuotasByOrg(db, [], new Date('2026-05-06T00:00:00Z'))).resolves.toEqual(new Map())
await expect(createQuotaRepo(db).getEffectiveQuotasByOrg([], new Date('2026-05-06T00:00:00Z'))).resolves.toEqual(
new Map(),
)
})
it('consumes traffic within the monthly quota and rejects overage', async () => {
@@ -442,9 +437,9 @@ describe('effective quota', () => {
.insert(orgQuotaEntitlements)
.values(entitlement(orgId, 'traffic', 'free-traffic-plan', 1000, 'active', now, 'Free'))
await expect(hasTrafficQuotaForBytes(db, orgId, 600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1, now)).resolves.toBe(false)
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 600, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 600, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1, now)).resolves.toBe(false)
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
expect(rows[0].trafficUsed).toBe(1000)
@@ -470,8 +465,8 @@ describe('effective quota', () => {
entitlement(orgId, 'traffic', 'traffic-overage', 500, 'active', now),
])
await expect(consumeTrafficIfQuotaAllows(db, orgId, 400, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 201, now)).resolves.toBe(false)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 400, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 201, now)).resolves.toBe(false)
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
expect(rows[0].trafficUsed).toBe(1300)
@@ -498,8 +493,8 @@ describe('effective quota', () => {
entitlement(orgId, 'traffic', 'traffic-pack', 500, 'active', now),
])
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1, now)).resolves.toBe(false)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1600, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1, now)).resolves.toBe(false)
})
it('allows subscription traffic overage when the active plan has an overage price', async () => {
@@ -532,10 +527,10 @@ describe('effective quota', () => {
),
])
await expect(hasTrafficQuotaForBytes(db, orgId, 1600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 100, now)).resolves.toBe(true)
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 1600, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1600, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 100, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
currentPlan: {
name: 'Pro Plan',
trafficBytes: 2000,
@@ -561,9 +556,9 @@ describe('effective quota', () => {
})
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'traffic', 'traffic-zero-base', 500, 'active', now))
await expect(hasTrafficQuotaForBytes(db, orgId, 100, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 100, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1, now)).resolves.toBe(false)
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 100, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 100, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1, now)).resolves.toBe(false)
})
it('refunds current monthly traffic usage', async () => {
@@ -580,7 +575,7 @@ describe('effective quota', () => {
trafficPeriod: '2026-05',
})
await refundTraffic(db, orgId, 300, now)
await createQuotaRepo(db).refundTraffic(orgId, 300, now)
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
expect(rows[0].trafficUsed).toBe(400)
@@ -600,7 +595,7 @@ describe('effective quota', () => {
trafficPeriod: '2026-05',
})
await refundTraffic(db, orgId, 300, now)
await createQuotaRepo(db).refundTraffic(orgId, 300, now)
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
expect(rows[0].trafficUsed).toBe(0)
@@ -623,8 +618,8 @@ describe('effective quota', () => {
.insert(orgQuotaEntitlements)
.values(entitlement(orgId, 'traffic', 'free-traffic-plan', 1000, 'active', now, 'Free'))
await expect(consumeTrafficIfQuotaAllows(db, orgId, 600, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 401, now)).resolves.toBe(false)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 600, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 401, now)).resolves.toBe(false)
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
expect(rows[0].trafficUsed).toBe(600)
@@ -654,7 +649,7 @@ describe('effective quota', () => {
END
`)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 400, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 400, now)).resolves.toBe(true)
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
expect(rows[0].trafficUsed).toBe(700)
@@ -666,8 +661,8 @@ describe('effective quota', () => {
const orgId = nanoid()
const now = new Date('2026-05-06T00:00:00Z')
await expect(hasTrafficQuotaForBytes(db, orgId, 1024, now)).resolves.toBe(true)
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1024, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 1024, now)).resolves.toBe(true)
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1024, now)).resolves.toBe(true)
})
it('treats zero base storage quota as limited when storage entitlements exist', async () => {
@@ -685,8 +680,8 @@ describe('effective quota', () => {
})
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'storage', 'storage-zero-base', 500, 'active', now))
await expect(hasQuotaForBytes(db, orgId, 100)).resolves.toBe(true)
await expect(hasQuotaForBytes(db, orgId, 101)).resolves.toBe(false)
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 100)).resolves.toBe(true)
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 101)).resolves.toBe(false)
})
it('enforces storage against subscription plan plus extra entitlements', async () => {
@@ -710,8 +705,8 @@ describe('effective quota', () => {
entitlement(orgId, 'storage', 'storage-pack', 500, 'active', now),
])
await expect(hasQuotaForBytes(db, orgId, 1600)).resolves.toBe(true)
await expect(hasQuotaForBytes(db, orgId, 1601)).resolves.toBe(false)
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 1600)).resolves.toBe(true)
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 1601)).resolves.toBe(false)
})
it('atomically enforces storage against the largest active subscription plan plus extra entitlements', async () => {
@@ -736,8 +731,12 @@ describe('effective quota', () => {
entitlement(orgId, 'storage', 'storage-pack', 500, 'active', now),
])
await expect(incrementUsageIfEffectiveQuotaAllows(db, orgId, storageId, 1600, true, now)).resolves.toBe(true)
await expect(incrementUsageIfEffectiveQuotaAllows(db, orgId, storageId, 1, true, now)).resolves.toBe(false)
await expect(
createQuotaRepo(db).incrementUsageIfEffectiveQuotaAllows(orgId, storageId, 1600, true, now),
).resolves.toBe(true)
await expect(
createQuotaRepo(db).incrementUsageIfEffectiveQuotaAllows(orgId, storageId, 1, true, now),
).resolves.toBe(false)
})
})
@@ -1,42 +1,11 @@
import { and, eq, inArray, or, sql } from 'drizzle-orm'
import { orgQuotaEntitlements, orgQuotas, storages } from '../db/schema'
import type { Database } from '../platform/interface'
import { organization } from '../../db/auth-schema'
import { orgQuotaEntitlements, orgQuotas, storages } from '../../db/schema'
import { currentTrafficPeriod } from '../../domain/quota'
import type { Database } from '../../platform/interface'
import type { CurrentStoragePlan, EffectiveQuota, QuotaRepo } from '../../usecases/ports'
export interface EffectiveQuota {
orgId: string
baseQuota: number
entitlementQuota: number
quota: number
used: number
baseTrafficQuota: number
entitlementTrafficQuota: number
trafficQuota: number
trafficUsed: number
trafficPeriod: string
storagePlanName: string | null
storageExtraNames: string[]
trafficPlanName: string | null
trafficExtraNames: string[]
currentPlan: CurrentStoragePlan | null
}
export interface CurrentStoragePlan {
sourceId: string
packageId: string | null
name: string
storageBytes: number
trafficBytes: number
trafficOveragePriceCents: number | null
expiresAt: string | null
subscription: boolean
}
export function currentTrafficPeriod(now = new Date()): string {
const month = String(now.getUTCMonth() + 1).padStart(2, '0')
return `${now.getUTCFullYear()}-${month}`
}
export async function getEffectiveQuota(db: Database, orgId: string, now = new Date()): Promise<EffectiveQuota> {
async function getEffectiveQuota(db: Database, orgId: string, now = new Date()): Promise<EffectiveQuota> {
// Delegate to the batch path's single in-memory aggregation. It always
// returns an entry for every requested orgId (zero-filled when absent).
const byOrg = await getEffectiveQuotasByOrg(db, [orgId], now)
@@ -46,7 +15,7 @@ export async function getEffectiveQuota(db: Database, orgId: string, now = new D
// Batch variant of getEffectiveQuota for list views. Resolves every org with two
// queries total (quota rows + active entitlements) instead of ~8 per org, then
// aggregates in memory. Returns one entry per requested orgId, even with no rows.
export async function getEffectiveQuotasByOrg(
async function getEffectiveQuotasByOrg(
db: Database,
orgIds: string[],
now = new Date(),
@@ -192,7 +161,7 @@ function extraEntitlementNames(ents: EntitlementRow[], resourceType: 'storage' |
// Idempotent: the WHERE clause matches nothing once a period has been reset, so it
// is safe to run on a schedule. getEffectiveQuota already normalizes stale periods
// in memory, so reads stay correct even between scheduled runs.
export async function resetExpiredTrafficQuotas(db: Database, now = new Date()): Promise<void> {
async function resetExpiredTrafficQuotas(db: Database, now = new Date()): Promise<void> {
const period = currentTrafficPeriod(now)
await db
.update(orgQuotas)
@@ -200,19 +169,14 @@ export async function resetExpiredTrafficQuotas(db: Database, now = new Date()):
.where(sql`${orgQuotas.trafficPeriod} != ${period}`)
}
export async function hasQuotaForBytes(db: Database, orgId: string, bytes: number): Promise<boolean> {
async function hasQuotaForBytes(db: Database, orgId: string, bytes: number): Promise<boolean> {
if (bytes <= 0) return true
const quota = await getEffectiveQuota(db, orgId)
if (quota.baseQuota === 0 && quota.entitlementQuota === 0) return true
return quota.used + bytes <= quota.quota
}
export async function hasTrafficQuotaForBytes(
db: Database,
orgId: string,
bytes: number,
now = new Date(),
): Promise<boolean> {
async function hasTrafficQuotaForBytes(db: Database, orgId: string, bytes: number, now = new Date()): Promise<boolean> {
if (bytes <= 0) return true
const quota = await getEffectiveQuota(db, orgId, now)
if (quota.baseTrafficQuota === 0 && quota.entitlementTrafficQuota === 0) return true
@@ -220,7 +184,7 @@ export async function hasTrafficQuotaForBytes(
return quota.trafficUsed + bytes <= quota.trafficQuota
}
export async function consumeTrafficIfQuotaAllows(
async function consumeTrafficIfQuotaAllows(
db: Database,
orgId: string,
bytes: number,
@@ -291,7 +255,7 @@ async function hasActiveTrafficOverage(db: Database, orgId: string, now: Date) {
return (trafficPlan?.trafficOveragePriceCents ?? 0) > 0
}
export async function refundTraffic(db: Database, orgId: string, bytes: number, now = new Date()): Promise<void> {
async function refundTraffic(db: Database, orgId: string, bytes: number, now = new Date()): Promise<void> {
if (bytes <= 0) return
const period = currentTrafficPeriod(now)
await db
@@ -302,7 +266,7 @@ export async function refundTraffic(db: Database, orgId: string, bytes: number,
.where(sql`${orgQuotas.orgId} = ${orgId} AND ${orgQuotas.trafficPeriod} = ${period}`)
}
export async function incrementUsageIfEffectiveQuotaAllows(
async function incrementUsageIfEffectiveQuotaAllows(
db: Database,
orgId: string,
storageId: string,
@@ -483,3 +447,31 @@ function entitlementName(metadata: string | null) {
function isSubscriptionSourceId(sourceId: string) {
return sourceId.startsWith('stripe_subscription:')
}
async function listOrgQuotaOverview(db: Database) {
return db
.select({
id: orgQuotas.id,
orgId: orgQuotas.orgId,
orgName: organization.name,
orgMetadata: organization.metadata,
})
.from(orgQuotas)
.innerJoin(organization, eq(organization.id, orgQuotas.orgId))
.orderBy(organization.name)
}
export function createQuotaRepo(db: Database): QuotaRepo {
return {
listOrgQuotaOverview: () => listOrgQuotaOverview(db),
getEffectiveQuota: (orgId, now) => getEffectiveQuota(db, orgId, now),
getEffectiveQuotasByOrg: (orgIds, now) => getEffectiveQuotasByOrg(db, orgIds, now),
resetExpiredTrafficQuotas: (now) => resetExpiredTrafficQuotas(db, now),
hasQuotaForBytes: (orgId, bytes) => hasQuotaForBytes(db, orgId, bytes),
hasTrafficQuotaForBytes: (orgId, bytes, now) => hasTrafficQuotaForBytes(db, orgId, bytes, now),
consumeTrafficIfQuotaAllows: (orgId, bytes, now) => consumeTrafficIfQuotaAllows(db, orgId, bytes, now),
refundTraffic: (orgId, bytes, now) => refundTraffic(db, orgId, bytes, now),
incrementUsageIfEffectiveQuotaAllows: (orgId, storageId, bytes, teamQuotaEnabled, now) =>
incrementUsageIfEffectiveQuotaAllows(db, orgId, storageId, bytes, teamQuotaEnabled, now),
}
}
@@ -0,0 +1,74 @@
import { asc, eq, inArray } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { remoteDownloadUsageReports } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type {
InsertRemoteDownloadUsageReportInput,
RemoteDownloadUsageRepo,
RemoteDownloadUsageReportRecord,
RemoteDownloadUsageStatus,
} from '../../usecases/ports'
function toRecord(row: typeof remoteDownloadUsageReports.$inferSelect): RemoteDownloadUsageReportRecord {
return {
id: row.id,
orgId: row.orgId,
downloaderId: row.downloaderId,
taskId: row.taskId,
eventId: row.eventId,
unitIndex: row.unitIndex,
unitBytes: row.unitBytes,
creditsPerUnit: row.creditsPerUnit,
status: row.status as RemoteDownloadUsageStatus,
error: row.error,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
export function createRemoteDownloadUsageRepo(db: Database): RemoteDownloadUsageRepo {
return {
async findByEventId(eventId) {
const rows = await db
.select()
.from(remoteDownloadUsageReports)
.where(eq(remoteDownloadUsageReports.eventId, eventId))
.limit(1)
return rows[0] ? toRecord(rows[0]) : undefined
},
async insert(input: InsertRemoteDownloadUsageReportInput) {
await db.insert(remoteDownloadUsageReports).values({
id: nanoid(),
orgId: input.orgId,
downloaderId: input.downloaderId,
taskId: input.taskId,
eventId: input.eventId,
unitIndex: input.unitIndex,
unitBytes: input.unitBytes,
creditsPerUnit: input.creditsPerUnit,
status: 'pending',
error: null,
createdAt: input.now,
updatedAt: input.now,
})
},
async updateStatus(eventId, status, error, now) {
await db
.update(remoteDownloadUsageReports)
.set({ status, error, updatedAt: now })
.where(eq(remoteDownloadUsageReports.eventId, eventId))
},
async listPending(limit) {
const rows = await db
.select()
.from(remoteDownloadUsageReports)
.where(inArray(remoteDownloadUsageReports.status, ['pending', 'failed']))
.orderBy(asc(remoteDownloadUsageReports.createdAt))
.limit(limit)
return rows.map(toRecord)
},
}
}
@@ -0,0 +1,13 @@
import { eq } from 'drizzle-orm'
import { user } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import type { ShareNotificationRepo } from '../../usecases/ports'
export function createShareNotificationRepo(db: Database): ShareNotificationRepo {
return {
async getUserEmail(userId) {
const rows = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1)
return rows[0]?.email ?? null
},
}
}
@@ -1,16 +1,20 @@
import { env } from 'cloudflare:workers'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import { createCloudflarePlatform } from '../platform/cloudflare'
import {
cascadeDeleteByMatter,
createShare,
incrementDownloadsAtomic,
resolveShareByToken,
revokeShareByToken,
} from './share'
import { DirType } from '../../../shared/constants'
import type { CreateShareInput } from '../../../shared/schemas/share'
import { matters } from '../../db/schema'
import { createCloudflarePlatform } from '../../platform/cloudflare'
import type { Database } from '../../platform/interface'
import { createShareRepo } from './share'
const createShare = (db: Database, input: CreateShareInput) => createShareRepo(db).create(input)
const resolveShareByToken = (db: Database, token: string) => createShareRepo(db).resolveByToken(token)
const incrementDownloadsAtomic = (db: Database, shareId: string) =>
createShareRepo(db).incrementDownloadsAtomic(shareId)
const revokeShareByToken = (db: Database, token: string, creatorId: string) =>
createShareRepo(db).revokeByToken(token, creatorId)
const cascadeDeleteByMatter = (db: Database, matterId: string) => createShareRepo(db).cascadeDeleteByMatter(matterId)
function buildDb() {
return createCloudflarePlatform(env).db
@@ -1,20 +1,28 @@
import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import { createTestApp } from '../test/setup.js'
import {
cascadeDeleteByMatter,
createShare,
incrementDownloadsAtomic,
incrementViews,
isAccessibleByUser,
listShareRecipientUserIds,
resolveShareByToken,
revokeShareByToken,
verifyPassword,
} from './share.js'
import { DirType } from '../../../shared/constants'
import type { CreateShareInput } from '../../../shared/schemas/share'
import { matters } from '../../db/schema'
import { isAccessibleByUser } from '../../domain/share'
import { verifyPassword as verifyPasswordHash } from '../../lib/password'
import type { Database } from '../../platform/interface'
import { createTestApp } from '../../test/setup.js'
import { createShareRepo } from './share.js'
// Thin adapters so the existing call sites stay readable: the repo is the unit
// under test, constructed per call from the test db.
const createShare = (db: Database, input: CreateShareInput) => createShareRepo(db).create(input)
const resolveShareByToken = (db: Database, token: string) => createShareRepo(db).resolveByToken(token)
const incrementViews = (db: Database, shareId: string) => createShareRepo(db).incrementViews(shareId)
const incrementDownloadsAtomic = (db: Database, shareId: string) =>
createShareRepo(db).incrementDownloadsAtomic(shareId)
const revokeShareByToken = (db: Database, token: string, creatorId: string) =>
createShareRepo(db).revokeByToken(token, creatorId)
const listShareRecipientUserIds = (db: Database, shareId: string) => createShareRepo(db).listRecipientUserIds(shareId)
const cascadeDeleteByMatter = (db: Database, matterId: string) => createShareRepo(db).cascadeDeleteByMatter(matterId)
const verifyPassword = (share: { passwordHash: string | null }, plaintext: string): boolean =>
share.passwordHash ? verifyPasswordHash(share.passwordHash, plaintext) : false
// ─── Helpers ─────────────────────────────────────────────────────────────────
+359
View File
@@ -0,0 +1,359 @@
import { DirType } from '@shared/constants'
import type { CreateShareInput } from '@shared/schemas/share'
import { and, count, desc, eq, inArray, isNotNull, isNull, like, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { user } from '../../db/auth-schema'
import { matters, shareRecipients, shares } from '../../db/schema'
import { type AtomicQuery, executeWriteTransaction } from '../../db/transaction'
import { hashPassword } from '../../lib/password'
import type { Database } from '../../platform/interface'
import {
CreateShareError,
type Matter,
type ShareListItem,
type ShareRecord,
type ShareRepo,
type ShareResolution,
} from '../../usecases/ports'
import { createQuotaRepo } from './quota'
function buildPath(parent: string, name: string): string {
return parent ? `${parent}/${name}` : name
}
// Escape LIKE wildcards so user-controlled folder names don't act as patterns.
function escapeLike(s: string): string {
return s.replace(/[\\%_]/g, '\\$&')
}
export function createShareRepo(db: Database): ShareRepo {
const quota = createQuotaRepo(db)
return {
async create(input: CreateShareInput): Promise<ShareRecord> {
if (input.kind === 'direct' && input.password) throw new CreateShareError('DIRECT_NO_PASSWORD')
if (input.kind === 'direct' && input.recipients && input.recipients.length > 0)
throw new CreateShareError('DIRECT_NO_RECIPIENTS')
const matter = await db
.select()
.from(matters)
.where(and(eq(matters.id, input.matterId), eq(matters.orgId, input.orgId)))
.then((rows) => rows[0] ?? null)
if (!matter) throw new CreateShareError('MATTER_NOT_FOUND')
if (input.kind === 'direct' && matter.dirtype !== DirType.FILE) throw new CreateShareError('DIRECT_NO_FOLDER')
const now = new Date()
const token = input.kind === 'direct' ? `ds_${nanoid(10)}` : nanoid(10)
const share: ShareRecord = {
id: nanoid(),
token,
kind: input.kind,
matterId: input.matterId,
orgId: input.orgId,
creatorId: input.creatorId,
passwordHash: input.password ? hashPassword(input.password) : null,
expiresAt: input.expiresAt ?? null,
downloadLimit: input.downloadLimit ?? null,
views: 0,
downloads: 0,
status: 'active',
createdAt: now,
}
const queries: AtomicQuery[] = [db.insert(shares).values(share)]
if (input.recipients && input.recipients.length > 0) {
const recipientRows = input.recipients.map((r) => ({
id: nanoid(),
shareId: share.id,
recipientUserId: r.recipientUserId ?? null,
recipientEmail: r.recipientEmail ?? null,
createdAt: now,
}))
queries.push(db.insert(shareRecipients).values(recipientRows))
}
await executeWriteTransaction(db, queries)
return share
},
async resolveByToken(token: string): Promise<ShareResolution> {
const rows = await db
.select({ share: shares, matter: matters })
.from(shares)
.innerJoin(matters, eq(shares.matterId, matters.id))
.where(eq(shares.token, token))
const row = rows[0]
if (!row) return { status: 'not_found' }
if (row.share.status === 'revoked') return { status: 'revoked' }
if (row.matter.status === 'trashed') return { status: 'matter_trashed' }
const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, row.share.id))
return { status: 'ok', share: row.share, matter: row.matter, recipients }
},
async incrementViews(shareId: string): Promise<void> {
await db
.update(shares)
.set({ views: sql`${shares.views} + 1` })
.where(eq(shares.id, shareId))
},
async hasDownloadsAvailable(shareId: string): Promise<boolean> {
const nowSecs = Math.floor(Date.now() / 1000)
const rows = await db
.select({ id: shares.id })
.from(shares)
.where(
and(
eq(shares.id, shareId),
eq(shares.status, 'active'),
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
),
)
.limit(1)
return rows.length === 1
},
async incrementDownloadsAtomic(shareId: string): Promise<{ ok: boolean; downloads: number }> {
const nowSecs = Math.floor(Date.now() / 1000)
const result = await db
.update(shares)
.set({ downloads: sql`${shares.downloads} + 1` })
.where(
and(
eq(shares.id, shareId),
eq(shares.status, 'active'),
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
),
)
.returning({ downloads: shares.downloads })
if (result.length === 1) {
return { ok: true, downloads: result[0].downloads }
}
const current = await db.select({ downloads: shares.downloads }).from(shares).where(eq(shares.id, shareId))
if (!current[0]) throw new Error('SHARE_NOT_FOUND')
return { ok: false, downloads: current[0].downloads }
},
async decrementDownloads(shareId: string): Promise<void> {
await db
.update(shares)
.set({ downloads: sql`CASE WHEN ${shares.downloads} > 0 THEN ${shares.downloads} - 1 ELSE 0 END` })
.where(eq(shares.id, shareId))
},
async listRecipientUserIds(shareId: string): Promise<string[]> {
const rows = await db
.select({ userId: shareRecipients.recipientUserId })
.from(shareRecipients)
.where(and(eq(shareRecipients.shareId, shareId), isNotNull(shareRecipients.recipientUserId)))
return rows.map((r) => r.userId as string)
},
async cascadeDeleteByMatter(matterId: string): Promise<void> {
const shareRows = await db.select({ id: shares.id }).from(shares).where(eq(shares.matterId, matterId))
if (shareRows.length === 0) return
const shareIds = shareRows.map((r) => r.id)
await executeWriteTransaction(db, [
db.delete(shareRecipients).where(inArray(shareRecipients.shareId, shareIds)),
db.delete(shares).where(inArray(shares.id, shareIds)),
])
},
async getCreatorByToken(token: string): Promise<string | null> {
const rows = await db.select({ creatorId: shares.creatorId }).from(shares).where(eq(shares.token, token))
return rows[0]?.creatorId ?? null
},
async revokeByToken(token: string, creatorId: string): Promise<boolean> {
const result = await db
.update(shares)
.set({ status: 'revoked' })
.where(and(eq(shares.token, token), eq(shares.creatorId, creatorId)))
.returning({ id: shares.id })
return result.length > 0
},
async listForApi(
creatorId: string,
opts: { page: number; pageSize: number; status?: string },
): Promise<{ items: ShareListItem[]; total: number }> {
const conditions = [eq(shares.creatorId, creatorId)]
if (opts.status) conditions.push(eq(shares.status, opts.status))
const where = and(...conditions)
const [countRow] = await db.select({ count: count() }).from(shares).where(where)
const total = countRow?.count ?? 0
const offset = (opts.page - 1) * opts.pageSize
const rows = await db
.select({
id: shares.id,
token: shares.token,
kind: shares.kind,
matterId: shares.matterId,
orgId: shares.orgId,
creatorId: shares.creatorId,
expiresAt: shares.expiresAt,
downloadLimit: shares.downloadLimit,
views: shares.views,
downloads: shares.downloads,
status: shares.status,
createdAt: shares.createdAt,
matterName: matters.name,
matterType: matters.type,
matterDirtype: matters.dirtype,
recipientCount: count(shareRecipients.id),
})
.from(shares)
.leftJoin(matters, eq(shares.matterId, matters.id))
.leftJoin(shareRecipients, eq(shareRecipients.shareId, shares.id))
.where(where)
.groupBy(shares.id)
.orderBy(desc(shares.createdAt))
.limit(opts.pageSize)
.offset(offset)
const items: ShareListItem[] = rows.map(
({ matterName, matterType, matterDirtype, recipientCount, ...share }) => ({
...share,
matter: { name: matterName ?? '', type: matterType ?? '', dirtype: matterDirtype ?? 0 },
recipientCount,
}),
)
return { items, total }
},
async listReceivedForApi(
userId: string,
userEmail: string | null,
opts: { page: number; pageSize: number },
): Promise<{ items: ShareListItem[]; total: number }> {
const recipientMatch = userEmail
? or(eq(shareRecipients.recipientUserId, userId), eq(shareRecipients.recipientEmail, userEmail))
: eq(shareRecipients.recipientUserId, userId)
const where = and(eq(shares.status, 'active'), recipientMatch)
const [countRow] = await db
.select({ count: sql<number>`COUNT(DISTINCT ${shares.id})` })
.from(shares)
.innerJoin(shareRecipients, eq(shareRecipients.shareId, shares.id))
.where(where)
const total = countRow?.count ?? 0
const offset = (opts.page - 1) * opts.pageSize
const rows = await db
.select({
id: shares.id,
token: shares.token,
kind: shares.kind,
matterId: shares.matterId,
orgId: shares.orgId,
creatorId: shares.creatorId,
expiresAt: shares.expiresAt,
downloadLimit: shares.downloadLimit,
views: shares.views,
downloads: shares.downloads,
status: shares.status,
createdAt: shares.createdAt,
matterName: matters.name,
matterType: matters.type,
matterDirtype: matters.dirtype,
creatorName: sql<string | null>`(SELECT name FROM user WHERE user.id = ${shares.creatorId})`,
})
.from(shares)
.innerJoin(shareRecipients, eq(shareRecipients.shareId, shares.id))
.leftJoin(matters, eq(shares.matterId, matters.id))
.where(where)
.groupBy(shares.id)
.orderBy(desc(shares.createdAt))
.limit(opts.pageSize)
.offset(offset)
const items: ShareListItem[] = rows.map(({ matterName, matterType, matterDirtype, creatorName, ...share }) => ({
...share,
matter: { name: matterName ?? '', type: matterType ?? '', dirtype: matterDirtype ?? 0 },
recipientCount: 0,
creatorName: creatorName ?? undefined,
}))
return { items, total }
},
async computeSourceBytes(matter: Matter): Promise<number> {
if (matter.dirtype === DirType.FILE) return matter.size ?? 0
const folderPath = buildPath(matter.parent, matter.name)
const rows = await db
.select({ size: matters.size })
.from(matters)
.where(
and(
eq(matters.orgId, matter.orgId),
eq(matters.status, 'active'),
eq(matters.dirtype, DirType.FILE),
or(eq(matters.parent, folderPath), like(matters.parent, `${folderPath}/%`)),
),
)
return rows.reduce((acc, r) => acc + (r.size ?? 0), 0)
},
async listDirectActiveChildren(orgId: string, folderPath: string): Promise<Matter[]> {
return db
.select()
.from(matters)
.where(and(eq(matters.orgId, orgId), eq(matters.parent, folderPath), eq(matters.status, 'active')))
},
hasQuotaForBytes(orgId: string, bytes: number): Promise<boolean> {
return quota.hasQuotaForBytes(orgId, bytes)
},
async getCreatorName(creatorId: string): Promise<string | null> {
const rows = await db.select({ name: user.name }).from(user).where(eq(user.id, creatorId)).limit(1)
return rows[0]?.name ?? null
},
async getUserEmail(userId: string): Promise<string | null> {
const rows = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1)
return rows[0]?.email ?? null
},
async getMatterName(matterId: string): Promise<string | null> {
const rows = await db.select({ name: matters.name }).from(matters).where(eq(matters.id, matterId)).limit(1)
return rows[0]?.name ?? null
},
async findShareChildMatter(
rootMatter: { id: string; orgId: string; parent: string; name: string },
childId: string,
): Promise<Matter | null> {
const root = buildPath(rootMatter.parent, rootMatter.name)
const likePattern = `${escapeLike(root)}/%`
const rows = await db
.select()
.from(matters)
.where(
and(
eq(matters.id, childId),
eq(matters.orgId, rootMatter.orgId),
eq(matters.status, 'active'),
or(eq(matters.parent, root), sql`${matters.parent} LIKE ${likePattern} ESCAPE '\\'`),
),
)
return rows[0] ?? null
},
}
}
@@ -1,10 +1,11 @@
import { DEFAULT_SITE_NAME } from '@shared/constants'
import type { SiteInvitation } from '@shared/types'
import { and, count, desc, eq, gt, isNull } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { DEFAULT_SITE_NAME } from '../../shared/constants'
import type { SiteInvitation } from '../../shared/types'
import * as authSchema from '../db/auth-schema'
import { siteInvitations, systemOptions } from '../db/schema'
import type { Database } from '../platform/interface'
import * as authSchema from '../../db/auth-schema'
import { siteInvitations, systemOptions } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { SiteInvitationRepo } from '../../usecases/ports'
const SITE_INVITE_EXPIRY_MS = 1000 * 60 * 60 * 24 * 7
@@ -37,12 +38,12 @@ function mapInvitation(row: typeof siteInvitations.$inferSelect & { invitedByNam
}
}
export async function getSiteName(db: Database): Promise<string> {
async function getSiteName(db: Database): Promise<string> {
const rows = await db.select().from(systemOptions).where(eq(systemOptions.key, 'site_name')).limit(1)
return rows[0]?.value?.trim() || DEFAULT_SITE_NAME
}
export async function listSiteInvitations(
async function listSiteInvitations(
db: Database,
page: number,
pageSize: number,
@@ -77,11 +78,7 @@ export async function listSiteInvitations(
}
}
export async function createSiteInvitation(
db: Database,
adminUserId: string,
rawEmail: string,
): Promise<SiteInvitation> {
async function createSiteInvitation(db: Database, adminUserId: string, rawEmail: string): Promise<SiteInvitation> {
const email = normalizeEmail(rawEmail)
const now = new Date()
@@ -138,7 +135,7 @@ export async function createSiteInvitation(
})
}
export async function resendSiteInvitation(
async function resendSiteInvitation(
db: Database,
invitationId: string,
): Promise<SiteInvitation | 'not_found' | 'already_accepted' | 'already_revoked'> {
@@ -181,7 +178,7 @@ export async function resendSiteInvitation(
return mapInvitation(updated)
}
export async function revokeSiteInvitation(
async function revokeSiteInvitation(
db: Database,
invitationId: string,
adminUserId: string,
@@ -203,7 +200,7 @@ export async function revokeSiteInvitation(
return 'ok'
}
export async function getSiteInvitationByToken(db: Database, token: string): Promise<SiteInvitation | null> {
async function getSiteInvitationByToken(db: Database, token: string): Promise<SiteInvitation | null> {
const [row] = await db
.select({
id: siteInvitations.id,
@@ -226,7 +223,7 @@ export async function getSiteInvitationByToken(db: Database, token: string): Pro
return row ? mapInvitation(row) : null
}
export async function validateSiteInvitation(
async function validateSiteInvitation(
db: Database,
token: string,
rawEmail: string,
@@ -241,7 +238,7 @@ export async function validateSiteInvitation(
return { valid: true }
}
export async function acceptSiteInvitation(
async function acceptSiteInvitation(
db: Database,
token: string,
rawEmail: string,
@@ -282,3 +279,16 @@ export async function acceptSiteInvitation(
if (updated?.acceptedBy) return 'accepted'
return 'accepted'
}
export function createSiteInvitationRepo(db: Database): SiteInvitationRepo {
return {
getSiteName: () => getSiteName(db),
listSiteInvitations: (page, pageSize) => listSiteInvitations(db, page, pageSize),
createSiteInvitation: (adminUserId, rawEmail) => createSiteInvitation(db, adminUserId, rawEmail),
resendSiteInvitation: (invitationId) => resendSiteInvitation(db, invitationId),
revokeSiteInvitation: (invitationId, adminUserId) => revokeSiteInvitation(db, invitationId, adminUserId),
getSiteInvitationByToken: (token) => getSiteInvitationByToken(db, token),
validateSiteInvitation: (token, rawEmail) => validateSiteInvitation(db, token, rawEmail),
acceptSiteInvitation: (token, rawEmail, userId) => acceptSiteInvitation(db, token, rawEmail, userId),
}
}
+74
View File
@@ -0,0 +1,74 @@
import { DirType } from '@shared/constants'
import { eq, sql } from 'drizzle-orm'
import { imageHostings, matters, orgQuotas, storages } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { StorageUsageRepo } from '../../usecases/ports'
export function createStorageUsageRepo(db: Database): StorageUsageRepo {
return {
async rollbackReservations(reservations) {
const bytesByStorage = new Map<string, number>()
const bytesByOrg = new Map<string, number>()
for (const reservation of reservations) {
if (!reservation || reservation.bytes <= 0) continue
bytesByStorage.set(reservation.storageId, (bytesByStorage.get(reservation.storageId) ?? 0) + reservation.bytes)
bytesByOrg.set(reservation.orgId, (bytesByOrg.get(reservation.orgId) ?? 0) + reservation.bytes)
}
for (const [storageId, bytes] of bytesByStorage) {
await db
.update(storages)
.set({ used: sql`MAX(0, ${storages.used} - ${bytes})` })
.where(eq(storages.id, storageId))
}
for (const [orgId, bytes] of bytesByOrg) {
await db
.update(orgQuotas)
.set({ used: sql`MAX(0, ${orgQuotas.used} - ${bytes})` })
.where(eq(orgQuotas.orgId, orgId))
}
},
async reconcile(orgId, storageIds = []) {
await db
.update(orgQuotas)
.set({
used: sql`COALESCE((
SELECT SUM(${matters.size})
FROM ${matters}
WHERE ${matters.orgId} = ${orgId}
AND ${matters.dirtype} = ${DirType.FILE}
AND ${matters.status} IN ('active', 'trashed')
), 0) + COALESCE((
SELECT SUM(${imageHostings.size})
FROM ${imageHostings}
WHERE ${imageHostings.orgId} = ${orgId}
AND ${imageHostings.status} = 'active'
), 0)`,
})
.where(eq(orgQuotas.orgId, orgId))
for (const storageId of new Set(storageIds)) {
await db
.update(storages)
.set({
used: sql`COALESCE((
SELECT SUM(${matters.size})
FROM ${matters}
WHERE ${matters.storageId} = ${storageId}
AND ${matters.dirtype} = ${DirType.FILE}
AND ${matters.status} IN ('active', 'trashed')
), 0) + COALESCE((
SELECT SUM(${imageHostings.size})
FROM ${imageHostings}
WHERE ${imageHostings.storageId} = ${storageId}
AND ${imageHostings.status} = 'active'
), 0)`,
})
.where(eq(storages.id, storageId))
}
},
}
}
@@ -1,12 +1,12 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { createTestApp } from '../test/setup.js'
import { createStorage, deleteStorage, getStorage, listStorages, selectStorage, updateStorage } from './storage.js'
import { createTestApp } from '../../test/setup.js'
import { createStorageRepo } from './storage.js'
describe('createStorage', () => {
it('sets filePath to empty string regardless of input', async () => {
const { db } = await createTestApp()
const result = await createStorage(db, {
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
@@ -21,7 +21,7 @@ describe('createStorage', () => {
it('sets customHost to empty string when not provided', async () => {
const { db } = await createTestApp()
const result = await createStorage(db, {
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
@@ -36,7 +36,7 @@ describe('createStorage', () => {
it('uses provided customHost when given', async () => {
const { db } = await createTestApp()
const result = await createStorage(db, {
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
@@ -52,7 +52,7 @@ describe('createStorage', () => {
it('sets capacity to 0 when not provided', async () => {
const { db } = await createTestApp()
const result = await createStorage(db, {
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
@@ -67,7 +67,7 @@ describe('createStorage', () => {
it('uses provided capacity when given', async () => {
const { db } = await createTestApp()
const result = await createStorage(db, {
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'private',
bucket: 'my-bucket',
@@ -82,7 +82,7 @@ describe('createStorage', () => {
it('initialises used to 0 and status to active', async () => {
const { db } = await createTestApp()
const result = await createStorage(db, {
const result = await createStorageRepo(db).create({
title: 'My Storage',
mode: 'public',
bucket: 'my-bucket',
@@ -98,7 +98,7 @@ describe('createStorage', () => {
it('persists the created row to the database', async () => {
const { db } = await createTestApp()
const created = await createStorage(db, {
const created = await createStorageRepo(db).create({
title: 'Persisted',
mode: 'private',
bucket: 'my-bucket',
@@ -108,7 +108,7 @@ describe('createStorage', () => {
secretKey: 'SECRET',
capacity: 0,
})
const fetched = await getStorage(db, created.id)
const fetched = await createStorageRepo(db).get(created.id)
expect(fetched?.id).toBe(created.id)
expect(fetched?.title).toBe('Persisted')
})
@@ -116,7 +116,7 @@ describe('createStorage', () => {
describe('updateStorage', () => {
async function seed(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
return createStorage(db, {
return createStorageRepo(db).create({
title: 'Original',
mode: 'private',
bucket: 'original-bucket',
@@ -131,14 +131,14 @@ describe('updateStorage', () => {
it('returns null when storage does not exist', async () => {
const { db } = await createTestApp()
const result = await updateStorage(db, 'nonexistent', { title: 'New' })
const result = await createStorageRepo(db).update('nonexistent', { title: 'New' })
expect(result).toBeNull()
})
it('keeps existing values for fields not included in update', async () => {
const { db } = await createTestApp()
const created = await seed(db)
const updated = await updateStorage(db, created.id, { title: 'Changed' })
const updated = await createStorageRepo(db).update(created.id, { title: 'Changed' })
expect(updated?.bucket).toBe('original-bucket')
expect(updated?.region).toBe('us-east-1')
expect(updated?.accessKey).toBe('AKID')
@@ -150,7 +150,7 @@ describe('updateStorage', () => {
it('applies all provided optional fields', async () => {
const { db } = await createTestApp()
const created = await seed(db)
const updated = await updateStorage(db, created.id, {
const updated = await createStorageRepo(db).update(created.id, {
title: 'Updated',
mode: 'public',
bucket: 'new-bucket',
@@ -177,7 +177,7 @@ describe('updateStorage', () => {
it('updates only status leaving all other fields intact', async () => {
const { db } = await createTestApp()
const created = await seed(db)
const updated = await updateStorage(db, created.id, { status: 'disabled' })
const updated = await createStorageRepo(db).update(created.id, { status: 'disabled' })
expect(updated?.status).toBe('disabled')
expect(updated?.title).toBe('Original')
})
@@ -187,7 +187,7 @@ describe('updateStorage', () => {
const created = await seed(db)
const before = created.updatedAt.getTime()
await new Promise((r) => setTimeout(r, 10))
const updated = await updateStorage(db, created.id, { title: 'New Title' })
const updated = await createStorageRepo(db).update(created.id, { title: 'New Title' })
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(before)
})
})
@@ -195,13 +195,13 @@ describe('updateStorage', () => {
describe('listStorages', () => {
it('returns empty items and zero total when no storages exist', async () => {
const { db } = await createTestApp()
const result = await listStorages(db)
const result = await createStorageRepo(db).list()
expect(result).toEqual({ items: [], total: 0 })
})
it('returns all storages ordered by createdAt ascending', async () => {
const { db } = await createTestApp()
await createStorage(db, {
await createStorageRepo(db).create({
title: 'First',
mode: 'private',
bucket: 'b1',
@@ -211,7 +211,7 @@ describe('listStorages', () => {
secretKey: 'S1',
capacity: 0,
})
await createStorage(db, {
await createStorageRepo(db).create({
title: 'Second',
mode: 'public',
bucket: 'b2',
@@ -221,7 +221,7 @@ describe('listStorages', () => {
secretKey: 'S2',
capacity: 0,
})
const result = await listStorages(db)
const result = await createStorageRepo(db).list()
expect(result.total).toBe(2)
expect(result.items).toHaveLength(2)
})
@@ -230,13 +230,13 @@ describe('listStorages', () => {
describe('getStorage', () => {
it('returns null when storage does not exist', async () => {
const { db } = await createTestApp()
const result = await getStorage(db, 'nonexistent')
const result = await createStorageRepo(db).get('nonexistent')
expect(result).toBeNull()
})
it('returns the storage when it exists', async () => {
const { db } = await createTestApp()
const created = await createStorage(db, {
const created = await createStorageRepo(db).create({
title: 'Findable',
mode: 'private',
bucket: 'b',
@@ -246,7 +246,7 @@ describe('getStorage', () => {
secretKey: 'S',
capacity: 0,
})
const found = await getStorage(db, created.id)
const found = await createStorageRepo(db).get(created.id)
expect(found?.id).toBe(created.id)
})
})
@@ -257,7 +257,7 @@ describe('selectStorage', () => {
mode: 'private' | 'public',
opts: { capacity?: number; used?: number; status?: string } = {},
) {
return createStorage(db, {
return createStorageRepo(db).create({
title: 'Seed',
mode,
bucket: 'b',
@@ -272,39 +272,39 @@ describe('selectStorage', () => {
it('returns an active private storage with unlimited capacity', async () => {
const { db } = await createTestApp()
const created = await seedActive(db, 'private')
const found = await selectStorage(db, 'private')
const found = await createStorageRepo(db).select('private')
expect(found.id).toBe(created.id)
})
it('returns an active public storage when requested', async () => {
const { db } = await createTestApp()
const created = await seedActive(db, 'public')
const found = await selectStorage(db, 'public')
const found = await createStorageRepo(db).select('public')
expect(found.id).toBe(created.id)
})
it('throws when no active storage exists for the requested mode', async () => {
const { db } = await createTestApp()
await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage')
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
})
it('throws when storage is present but mode does not match', async () => {
const { db } = await createTestApp()
await seedActive(db, 'public')
await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage')
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
})
})
describe('deleteStorage', () => {
it('returns not_found when storage does not exist', async () => {
const { db } = await createTestApp()
const result = await deleteStorage(db, 'nonexistent')
const result = await createStorageRepo(db).delete('nonexistent')
expect(result).toBe('not_found')
})
it('deletes a storage that is not referenced by any matter', async () => {
const { db } = await createTestApp()
const created = await createStorage(db, {
const created = await createStorageRepo(db).create({
title: 'Deletable',
mode: 'private',
bucket: 'b',
@@ -314,14 +314,14 @@ describe('deleteStorage', () => {
secretKey: 'S',
capacity: 0,
})
const result = await deleteStorage(db, created.id)
const result = await createStorageRepo(db).delete(created.id)
expect(result).toBe('ok')
expect(await getStorage(db, created.id)).toBeNull()
expect(await createStorageRepo(db).get(created.id)).toBeNull()
})
it('returns in_use when matters reference the storage', async () => {
const { db } = await createTestApp()
const created = await createStorage(db, {
const created = await createStorageRepo(db).create({
title: 'In Use',
mode: 'private',
bucket: 'b',
@@ -336,7 +336,7 @@ describe('deleteStorage', () => {
INSERT INTO matters (id, org_id, alias, name, type, storage_id, created_at, updated_at)
VALUES ('m-ref', 'org-1', 'alias-ref', 'test.txt', 'text/plain', ${created.id}, ${now}, ${now})
`)
const result = await deleteStorage(db, created.id)
const result = await createStorageRepo(db).delete(created.id)
expect(result).toBe('in_use')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { and, asc, count, eq, lt, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { matters, storages } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { StorageRecord, StorageRepo } from '../../usecases/ports'
type StorageRow = typeof storages.$inferSelect
function toRecord(row: StorageRow): StorageRecord {
return row as StorageRecord
}
export function createStorageRepo(db: Database): StorageRepo {
async function getRow(id: string): Promise<StorageRow | null> {
const rows = await db.select().from(storages).where(eq(storages.id, id))
return rows[0] ?? null
}
return {
async list() {
const rows = await db.select().from(storages).orderBy(asc(storages.createdAt))
return { items: rows.map(toRecord), total: rows.length }
},
async get(id) {
const row = await getRow(id)
return row ? toRecord(row) : null
},
async create(input) {
const now = new Date()
const row: StorageRow = {
id: nanoid(),
title: input.title,
mode: input.mode,
bucket: input.bucket,
endpoint: input.endpoint,
region: input.region ?? 'auto',
accessKey: input.accessKey,
secretKey: input.secretKey,
filePath: '',
customHost: input.customHost ?? '',
capacity: input.capacity ?? 0,
egressCreditBillingEnabled: input.egressCreditBillingEnabled ?? false,
egressCreditUnitBytes: input.egressCreditUnitBytes ?? 104857600,
egressCreditPerUnit: input.egressCreditPerUnit ?? 1,
used: 0,
status: 'active',
createdAt: now,
updatedAt: now,
}
await db.insert(storages).values(row)
return toRecord(row)
},
async count() {
const rows = await db.select({ count: count() }).from(storages)
return rows[0]?.count ?? 0
},
async update(id, input) {
const existing = await getRow(id)
if (!existing) return null
const now = new Date()
const updated = {
title: input.title ?? existing.title,
mode: input.mode ?? existing.mode,
bucket: input.bucket ?? existing.bucket,
endpoint: input.endpoint ?? existing.endpoint,
region: input.region ?? existing.region,
accessKey: input.accessKey ?? existing.accessKey,
secretKey: input.secretKey ?? existing.secretKey,
customHost: input.customHost ?? existing.customHost,
capacity: input.capacity ?? existing.capacity,
egressCreditBillingEnabled: input.egressCreditBillingEnabled ?? existing.egressCreditBillingEnabled,
egressCreditUnitBytes: input.egressCreditUnitBytes ?? existing.egressCreditUnitBytes,
egressCreditPerUnit: input.egressCreditPerUnit ?? existing.egressCreditPerUnit,
status: input.status ?? existing.status,
updatedAt: now,
}
await db.update(storages).set(updated).where(eq(storages.id, id))
return toRecord({ ...existing, ...updated })
},
async delete(id) {
const existing = await getRow(id)
if (!existing) return 'not_found'
const refs = await db.select({ count: count() }).from(matters).where(eq(matters.storageId, id))
if ((refs[0]?.count ?? 0) > 0) return 'in_use'
await db.delete(storages).where(eq(storages.id, id))
return 'ok'
},
async select(mode) {
const rows = await db
.select()
.from(storages)
.where(
and(
eq(storages.mode, mode),
eq(storages.status, 'active'),
or(eq(storages.capacity, 0), lt(storages.used, storages.capacity)),
),
)
.orderBy(asc(storages.createdAt))
.limit(1)
if (rows.length === 0) throw new Error('No available storage')
return toRecord(rows[0])
},
}
}
+54
View File
@@ -0,0 +1,54 @@
import { eq, like } from 'drizzle-orm'
import { systemOptions } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { SystemOption, SystemOptionsRepo } from '../../usecases/ports'
function toOption(row: { key: string; value: string; public: boolean | null }): SystemOption {
return { key: row.key, value: row.value, public: !!row.public }
}
export function createSystemOptionsRepo(db: Database): SystemOptionsRepo {
return {
async list() {
const rows = await db.select().from(systemOptions)
return rows.map(toOption)
},
async listPublic() {
const rows = await db.select().from(systemOptions).where(eq(systemOptions.public, true))
return rows.map(toOption)
},
async get(key) {
const rows = await db.select().from(systemOptions).where(eq(systemOptions.key, key)).limit(1)
return rows[0] ? toOption(rows[0]) : null
},
async getValue(key) {
const rows = await db
.select({ value: systemOptions.value })
.from(systemOptions)
.where(eq(systemOptions.key, key))
.limit(1)
return rows[0]?.value ?? null
},
async listByKeyLike(pattern) {
return db
.select({ key: systemOptions.key, value: systemOptions.value })
.from(systemOptions)
.where(like(systemOptions.key, pattern))
},
async set(key, value, isPublic) {
await db
.insert(systemOptions)
.values({ key, value, public: isPublic })
.onConflictDoUpdate({ target: systemOptions.key, set: { value, public: isPublic } })
},
async delete(key) {
await db.delete(systemOptions).where(eq(systemOptions.key, key))
},
}
}
@@ -1,8 +1,8 @@
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { createTestApp } from '../test/setup.js'
import { acceptInviteLink, createInviteLink, getInviteLinkInfo, listPendingInvitations } from './team-invite.js'
import * as authSchema from '../../db/auth-schema.js'
import { createTestApp } from '../../test/setup.js'
import { createTeamInviteRepo } from './team-invite.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -66,7 +66,7 @@ describe('createInviteLink', () => {
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
expect(link.token).toBeTruthy()
expect(link.organizationId).toBe(orgId)
expect(link.role).toBe('viewer')
@@ -81,7 +81,7 @@ describe('createInviteLink', () => {
const oneHour = 60 * 60 * 1000
const before = Date.now()
const link = await createInviteLink(db, orgId, inviterId, 'editor', oneHour)
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'editor', oneHour)
const after = Date.now()
expect(link.expiresAt!.getTime()).toBeGreaterThan(before + oneHour - 1000)
@@ -94,8 +94,8 @@ describe('createInviteLink', () => {
const inviterId = await insertUser(db)
const [a, b] = await Promise.all([
createInviteLink(db, orgId, inviterId, 'viewer'),
createInviteLink(db, orgId, inviterId, 'viewer'),
createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer'),
createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer'),
])
expect(a.token).not.toBe(b.token)
})
@@ -107,8 +107,8 @@ describe('getInviteLinkInfo', () => {
const orgId = await insertOrg(db, { name: 'My Team' })
const inviterId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'editor')
const info = await getInviteLinkInfo(db, link.token)
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'editor')
const info = await createTeamInviteRepo(db).getInviteLinkInfo(link.token)
expect(info).not.toBeNull()
expect(info!.organizationId).toBe(orgId)
@@ -118,7 +118,7 @@ describe('getInviteLinkInfo', () => {
it('returns null for an unknown token', async () => {
const { db } = await createTestApp()
const info = await getInviteLinkInfo(db, 'nonexistent-token')
const info = await createTeamInviteRepo(db).getInviteLinkInfo('nonexistent-token')
expect(info).toBeNull()
})
@@ -127,8 +127,8 @@ describe('getInviteLinkInfo', () => {
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer', -1000) // already expired
const info = await getInviteLinkInfo(db, link.token)
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer', -1000) // already expired
const info = await createTeamInviteRepo(db).getInviteLinkInfo(link.token)
expect(info).toBeNull()
})
})
@@ -140,15 +140,15 @@ describe('acceptInviteLink', () => {
const inviterId = await insertUser(db)
const userId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
const result = await acceptInviteLink(db, link.token, userId)
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
const result = await createTeamInviteRepo(db).acceptInviteLink(link.token, userId)
expect(result).toBe('ok')
})
it('returns invalid for a nonexistent token', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await acceptInviteLink(db, 'bad-token', userId)
const result = await createTeamInviteRepo(db).acceptInviteLink('bad-token', userId)
expect(result).toBe('invalid')
})
@@ -158,8 +158,8 @@ describe('acceptInviteLink', () => {
const inviterId = await insertUser(db)
const userId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer', -1000)
const result = await acceptInviteLink(db, link.token, userId)
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer', -1000)
const result = await createTeamInviteRepo(db).acceptInviteLink(link.token, userId)
expect(result).toBe('expired')
})
@@ -170,8 +170,8 @@ describe('acceptInviteLink', () => {
const userId = await insertUser(db)
await insertMember(db, orgId, userId, 'viewer')
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
const result = await acceptInviteLink(db, link.token, userId)
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
const result = await createTeamInviteRepo(db).acceptInviteLink(link.token, userId)
expect(result).toBe('already_member')
})
@@ -182,9 +182,9 @@ describe('acceptInviteLink', () => {
const user1 = await insertUser(db)
const user2 = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
const r1 = await acceptInviteLink(db, link.token, user1)
const r2 = await acceptInviteLink(db, link.token, user2)
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
const r1 = await createTeamInviteRepo(db).acceptInviteLink(link.token, user1)
const r2 = await createTeamInviteRepo(db).acceptInviteLink(link.token, user2)
expect(r1).toBe('ok')
expect(r2).toBe('ok')
})
@@ -194,7 +194,7 @@ describe('listPendingInvitations', () => {
it('returns empty list when no pending invitations', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const result = await listPendingInvitations(db, orgId)
const result = await createTeamInviteRepo(db).listPendingInvitations(orgId)
expect(result).toEqual([])
})
@@ -204,7 +204,7 @@ describe('listPendingInvitations', () => {
const inviterId = await insertUser(db)
await insertInvitation(db, orgId, inviterId, 'test@example.com')
const result = await listPendingInvitations(db, orgId)
const result = await createTeamInviteRepo(db).listPendingInvitations(orgId)
expect(result).toHaveLength(1)
expect(result[0].email).toBe('test@example.com')
expect(result[0].role).toBe('viewer')
@@ -217,7 +217,7 @@ describe('listPendingInvitations', () => {
await insertInvitation(db, orgId, inviterId, 'accepted@example.com', 'accepted')
await insertInvitation(db, orgId, inviterId, 'pending@example.com', 'pending')
const result = await listPendingInvitations(db, orgId)
const result = await createTeamInviteRepo(db).listPendingInvitations(orgId)
expect(result).toHaveLength(1)
expect(result[0].email).toBe('pending@example.com')
})
@@ -230,7 +230,7 @@ describe('listPendingInvitations', () => {
await insertInvitation(db, org1, inviterId, 'org1@example.com')
await insertInvitation(db, org2, inviterId, 'org2@example.com')
const result = await listPendingInvitations(db, org1)
const result = await createTeamInviteRepo(db).listPendingInvitations(org1)
expect(result).toHaveLength(1)
expect(result[0].email).toBe('org1@example.com')
})
+94
View File
@@ -0,0 +1,94 @@
import { and, desc, eq, gt, isNull, or } from 'drizzle-orm'
import { customAlphabet, nanoid } from 'nanoid'
import { invitation, member, organization } from '../../db/auth-schema'
import { teamInviteLinks } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type { TeamInviteLinkRecord, TeamInviteRepo } from '../../usecases/ports'
const generateToken = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 32)
const DEFAULT_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export function createTeamInviteRepo(db: Database): TeamInviteRepo {
return {
async createInviteLink(organizationId, inviterId, role, expiresIn) {
const token = generateToken()
const now = new Date()
const row: TeamInviteLinkRecord = {
id: nanoid(),
token,
organizationId,
role,
inviterId,
expiresAt: new Date(now.getTime() + (expiresIn ?? DEFAULT_EXPIRES_IN_MS)),
createdAt: now,
}
await db.insert(teamInviteLinks).values(row)
return row
},
async getInviteLinkInfo(token) {
const rows = await db
.select({
organizationId: teamInviteLinks.organizationId,
role: teamInviteLinks.role,
expiresAt: teamInviteLinks.expiresAt,
organizationName: organization.name,
})
.from(teamInviteLinks)
.innerJoin(organization, eq(teamInviteLinks.organizationId, organization.id))
.where(
and(
eq(teamInviteLinks.token, token),
or(isNull(teamInviteLinks.expiresAt), gt(teamInviteLinks.expiresAt, new Date())),
),
)
.limit(1)
const row = rows[0]
if (!row) return null
return {
organizationId: row.organizationId,
organizationName: row.organizationName,
role: row.role,
expiresAt: row.expiresAt,
}
},
async acceptInviteLink(token, userId) {
const rows = await db.select().from(teamInviteLinks).where(eq(teamInviteLinks.token, token)).limit(1)
const link = rows[0]
if (!link) return 'invalid'
if (link.expiresAt && link.expiresAt < new Date()) return 'expired'
const existing = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, link.organizationId), eq(member.userId, userId)))
.limit(1)
if (existing[0]) return 'already_member'
await db.insert(member).values({
id: nanoid(),
organizationId: link.organizationId,
userId,
role: link.role,
createdAt: new Date(),
})
return 'ok'
},
async listPendingInvitations(organizationId) {
return db
.select({
id: invitation.id,
email: invitation.email,
role: invitation.role,
expiresAt: invitation.expiresAt,
createdAt: invitation.createdAt,
})
.from(invitation)
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
.orderBy(desc(invitation.createdAt))
},
}
}
+133
View File
@@ -0,0 +1,133 @@
import { and, count, eq, inArray, like, not } from 'drizzle-orm'
import { member, organization, user } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import type { TeamRepo, TeamSummary } from '../../usecases/ports'
import { createQuotaRepo } from './quota'
// Personal orgs use the deterministic `personal-${userId}` slug; everything else
// is a team. Production teams may have null metadata, so the slug prefix is the
// reliable discriminator (not metadata.type).
const isTeamSlug = not(like(organization.slug, 'personal-%'))
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) {
out.push(items.slice(i, i + size))
}
return out
}
// First owner per org (by member creation order), for a display label.
async function listOwnerNames(db: Database, orgIds: string[]): Promise<Map<string, string>> {
// D1 caps a query at 100 bound params; chunk the IN list (plus the role param).
const chunks = await Promise.all(
chunk(orgIds, 90).map((ids) =>
db
.select({
orgId: member.organizationId,
name: user.name,
email: user.email,
createdAt: member.createdAt,
})
.from(member)
.innerJoin(user, eq(user.id, member.userId))
.where(and(eq(member.role, 'owner'), inArray(member.organizationId, ids)))
.orderBy(member.createdAt),
),
)
const byOrg = new Map<string, string>()
for (const r of chunks.flat()) {
if (!byOrg.has(r.orgId)) byOrg.set(r.orgId, r.name || r.email)
}
return byOrg
}
export function createTeamRepo(db: Database): TeamRepo {
const quota = createQuotaRepo(db)
return {
async listTeams() {
const rows = await db
.select({
id: organization.id,
name: organization.name,
slug: organization.slug,
logo: organization.logo,
createdAt: organization.createdAt,
})
.from(organization)
.where(isTeamSlug)
.orderBy(organization.name)
if (rows.length === 0) return []
const orgIds = rows.map((r) => r.id)
const quotas = await quota.getEffectiveQuotasByOrg(orgIds)
// D1 caps a query at 100 bound params; chunk the IN lists below the cap.
const memberChunks = await Promise.all(
chunk(orgIds, 90).map((ids) =>
db
.select({ orgId: member.organizationId, total: count() })
.from(member)
.where(inArray(member.organizationId, ids))
.groupBy(member.organizationId),
),
)
const memberByOrg = new Map(memberChunks.flat().map((r) => [r.orgId, r.total]))
const owners = await listOwnerNames(db, orgIds)
return rows.map((r) => {
const q = quotas.get(r.id)
return {
id: r.id,
name: r.name,
slug: r.slug,
logo: r.logo,
memberCount: memberByOrg.get(r.id) ?? 0,
ownerName: owners.get(r.id) ?? null,
quotaUsed: q?.used ?? 0,
quotaTotal: q?.quota ?? 0,
createdAt: r.createdAt.getTime(),
} satisfies TeamSummary
})
},
async getTeam(orgId) {
const rows = await db
.select({
id: organization.id,
name: organization.name,
slug: organization.slug,
logo: organization.logo,
createdAt: organization.createdAt,
})
.from(organization)
.where(and(eq(organization.id, orgId), isTeamSlug))
.limit(1)
const org = rows[0]
if (!org) return null
const q = await quota.getEffectiveQuota(orgId)
const [memberRow] = await db.select({ total: count() }).from(member).where(eq(member.organizationId, orgId))
const owners = await listOwnerNames(db, [orgId])
return {
id: org.id,
name: org.name,
slug: org.slug,
logo: org.logo,
memberCount: memberRow?.total ?? 0,
ownerName: owners.get(orgId) ?? null,
quotaUsed: q.used,
quotaTotal: q.quota,
createdAt: org.createdAt.getTime(),
} satisfies TeamSummary
},
async setLogo(orgId, logo) {
await db.update(organization).set({ logo }).where(eq(organization.id, orgId))
},
}
}
@@ -1,52 +1,18 @@
import { and, count, desc, eq, inArray, or, sql } from 'drizzle-orm'
import { member, organization, user } from '../db/auth-schema'
import { orgQuotaEntitlements, orgQuotas } from '../db/schema'
import type { Database } from '../platform/interface'
import {
grantOrgEntitlement,
listOrgEntitlements,
revokeOrgEntitlement,
updateOrgEntitlement,
} from './org-entitlements'
import { nanoid } from 'nanoid'
import { member, organization, user } from '../../db/auth-schema'
import { orgQuotaEntitlements, orgQuotas } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type {
GrantEntitlementInput,
QuotaEntitlementItem,
UpdateEntitlementInput,
UserAdminRepo,
UserOperationFailure,
UserWithOrg,
} from '../../usecases/ports'
export interface UserWithOrg {
id: string
name: string
username: string
email: string
image: string | null
role: string | null
banned: boolean | null
createdAt: Date
orgId: string | null
orgName: string | null
quotaUsed: number
quotaDefault: number
quotaTotal: number
}
export interface QuotaEntitlementItem {
id: string
orgId: string
resourceType: string
entitlementType: string
source: string
sourceId: string
bytes: number
startsAt: Date
expiresAt: Date | null
status: string
metadata: string | null
createdAt: Date
updatedAt: Date
}
export interface UserOperationFailure {
error: string
status: 400 | 404
}
export async function listUsers(
async function listUsers(
db: Database,
page: number,
pageSize: number,
@@ -101,7 +67,7 @@ export async function listUsers(
return { items, total }
}
export async function getUser(db: Database, userId: string): Promise<UserWithOrg | UserOperationFailure> {
async function getUser(db: Database, userId: string): Promise<UserWithOrg | UserOperationFailure> {
const now = new Date()
const rows = await db
.select({
@@ -148,7 +114,7 @@ function activeStorageEntitlementBytesSql(now: Date) {
)`
}
export async function setUserStatus(db: Database, userId: string, status: 'active' | 'disabled'): Promise<boolean> {
async function setUserStatus(db: Database, userId: string, status: 'active' | 'disabled'): Promise<boolean> {
const existing = await db.select({ id: user.id }).from(user).where(eq(user.id, userId))
if (existing.length === 0) return false
@@ -159,7 +125,23 @@ export async function setUserStatus(db: Database, userId: string, status: 'activ
return true
}
export async function deleteUser(db: Database, userId: string): Promise<boolean> {
async function isBanned(db: Database, userId: string): Promise<boolean> {
const rows = await db.select({ banned: user.banned }).from(user).where(eq(user.id, userId))
return Boolean(rows[0]?.banned)
}
async function matchesUsername(db: Database, userId: string, username: string): Promise<boolean> {
const rows = await db
.select({ email: user.email, username: user.username })
.from(user)
.where(eq(user.id, userId))
.limit(1)
const account = rows[0]
if (!account) return false
return account.email.toLowerCase() === username.toLowerCase() || account.username === username
}
async function deleteUser(db: Database, userId: string): Promise<boolean> {
const existing = await db.select({ id: user.id }).from(user).where(eq(user.id, userId))
if (existing.length === 0) return false
@@ -167,7 +149,7 @@ export async function deleteUser(db: Database, userId: string): Promise<boolean>
return true
}
export async function setUsersStatus(
async function setUsersStatus(
db: Database,
userIds: string[],
status: 'active' | 'disabled',
@@ -182,7 +164,7 @@ export async function setUsersStatus(
return { updated: existingIds.length, ids: existingIds }
}
export async function deleteUsers(
async function deleteUsers(
db: Database,
userIds: string[],
): Promise<{ deleted: number; ids: string[] } | UserOperationFailure> {
@@ -193,7 +175,7 @@ export async function deleteUsers(
return { deleted: existingIds.length, ids: existingIds }
}
export async function listUserPersonalEntitlements(
async function listUserPersonalEntitlements(
db: Database,
userId: string,
): Promise<{ orgId: string; items: QuotaEntitlementItem[] } | UserOperationFailure> {
@@ -202,7 +184,7 @@ export async function listUserPersonalEntitlements(
return listOrgEntitlements(db, org.orgId)
}
export async function grantUserPersonalEntitlement(
async function grantUserPersonalEntitlement(
db: Database,
input: {
adminUserId: string
@@ -218,7 +200,7 @@ export async function grantUserPersonalEntitlement(
return grantOrgEntitlement(db, { ...input, orgId: org.orgId })
}
export async function updateUserPersonalEntitlement(
async function updateUserPersonalEntitlement(
db: Database,
input: {
adminUserId: string
@@ -234,7 +216,7 @@ export async function updateUserPersonalEntitlement(
return updateOrgEntitlement(db, { ...input, orgId: org.orgId })
}
export async function revokeUserPersonalEntitlement(
async function revokeUserPersonalEntitlement(
db: Database,
input: { adminUserId: string; targetUserId: string; entitlementId: string },
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
@@ -270,3 +252,139 @@ async function requireUsers(db: Database, userIds: string[]): Promise<string[] |
}
return uniqueIds
}
// --- Org-scoped admin entitlement operations (formerly services/org-entitlements) ---
async function requireOrg(db: Database, orgId: string): Promise<{ orgId: string } | UserOperationFailure> {
const rows = await db.select({ id: organization.id }).from(organization).where(eq(organization.id, orgId)).limit(1)
if (!rows[0]) return { error: `Organization not found: ${orgId}`, status: 404 }
return { orgId }
}
async function listOrgEntitlements(
db: Database,
orgId: string,
): Promise<{ orgId: string; items: QuotaEntitlementItem[] } | UserOperationFailure> {
const org = await requireOrg(db, orgId)
if ('error' in org) return org
const items = await db
.select()
.from(orgQuotaEntitlements)
.where(eq(orgQuotaEntitlements.orgId, orgId))
.orderBy(desc(orgQuotaEntitlements.createdAt))
return { orgId, items }
}
async function grantOrgEntitlement(
db: Database,
input: GrantEntitlementInput,
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
const org = await requireOrg(db, input.orgId)
if ('error' in org) return org
const now = new Date()
const entitlement = {
id: nanoid(),
orgId: input.orgId,
resourceType: input.resourceType,
entitlementType: 'grant',
source: 'admin_grant',
sourceId: `admin_grant:${nanoid()}`,
bytes: input.bytes,
startsAt: now,
expiresAt: input.expiresAt ?? null,
status: 'active',
metadata: JSON.stringify({ note: input.note ?? null, grantedBy: input.adminUserId }),
createdAt: now,
updatedAt: now,
} satisfies typeof orgQuotaEntitlements.$inferInsert
const rows = await db.insert(orgQuotaEntitlements).values(entitlement).returning()
return { orgId: input.orgId, entitlement: rows[0] }
}
async function updateOrgEntitlement(
db: Database,
input: UpdateEntitlementInput,
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
const existing = await findAdminGrant(db, input.orgId, input.entitlementId)
if ('error' in existing) return existing
const metadata =
input.note === undefined
? existing.metadata
: mergeGrantMetadata(existing.metadata, { note: input.note, updatedBy: input.adminUserId })
const rows = await db
.update(orgQuotaEntitlements)
.set({
bytes: input.bytes ?? existing.bytes,
expiresAt: input.expiresAt === undefined ? existing.expiresAt : input.expiresAt,
metadata,
updatedAt: new Date(),
})
.where(eq(orgQuotaEntitlements.id, input.entitlementId))
.returning()
return { orgId: input.orgId, entitlement: rows[0] }
}
async function revokeOrgEntitlement(
db: Database,
input: { adminUserId: string; orgId: string; entitlementId: string },
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
const existing = await findAdminGrant(db, input.orgId, input.entitlementId)
if ('error' in existing) return existing
const rows = await db
.update(orgQuotaEntitlements)
.set({
status: 'revoked',
metadata: mergeGrantMetadata(existing.metadata, { revokedBy: input.adminUserId }),
updatedAt: new Date(),
})
.where(eq(orgQuotaEntitlements.id, input.entitlementId))
.returning()
return { orgId: input.orgId, entitlement: rows[0] }
}
async function findAdminGrant(
db: Database,
orgId: string,
entitlementId: string,
): Promise<QuotaEntitlementItem | UserOperationFailure> {
const rows = await db
.select()
.from(orgQuotaEntitlements)
.where(and(eq(orgQuotaEntitlements.id, entitlementId), eq(orgQuotaEntitlements.orgId, orgId)))
.limit(1)
const row = rows[0]
if (!row) return { error: `Entitlement not found: ${entitlementId}`, status: 404 }
if (row.source !== 'admin_grant') {
return { error: 'Only admin-granted entitlements can be modified', status: 400 }
}
return row
}
function mergeGrantMetadata(existing: string | null, patch: Record<string, unknown>): string {
const base = existing ? (JSON.parse(existing) as Record<string, unknown>) : {}
return JSON.stringify({ ...base, ...patch })
}
export function createUserAdminRepo(db: Database): UserAdminRepo {
return {
listUsers: (page, pageSize, search) => listUsers(db, page, pageSize, search),
getUser: (userId) => getUser(db, userId),
isBanned: (userId) => isBanned(db, userId),
matchesUsername: (userId, username) => matchesUsername(db, userId, username),
setUserStatus: (userId, status) => setUserStatus(db, userId, status),
deleteUser: (userId) => deleteUser(db, userId),
setUsersStatus: (userIds, status) => setUsersStatus(db, userIds, status),
deleteUsers: (userIds) => deleteUsers(db, userIds),
listUserPersonalEntitlements: (userId) => listUserPersonalEntitlements(db, userId),
grantUserPersonalEntitlement: (input) => grantUserPersonalEntitlement(db, input),
updateUserPersonalEntitlement: (input) => updateUserPersonalEntitlement(db, input),
revokeUserPersonalEntitlement: (input) => revokeUserPersonalEntitlement(db, input),
requireOrg: (orgId) => requireOrg(db, orgId),
listOrgEntitlements: (orgId) => listOrgEntitlements(db, orgId),
grantOrgEntitlement: (input) => grantOrgEntitlement(db, input),
updateOrgEntitlement: (input) => updateOrgEntitlement(db, input),
revokeOrgEntitlement: (input) => revokeOrgEntitlement(db, input),
}
}
+122
View File
@@ -0,0 +1,122 @@
import { ObjectStatus } from '@shared/constants'
import { and, asc, desc, eq } from 'drizzle-orm'
import { member, organization } from '../../db/auth-schema'
import { matters } from '../../db/schema'
import type { Database } from '../../platform/interface'
import {
type Matter,
WebDavPathError,
type WebDavPathRepo,
type WebDavTarget,
type WebDavWorkspace,
} from '../../usecases/ports'
export function createWebDavPathRepo(db: Database): WebDavPathRepo {
async function getUserWorkspace(userId: string, slugOrId: string): Promise<WebDavWorkspace | null> {
const rows = await db
.select({ id: organization.id, name: organization.name, slug: organization.slug })
.from(member)
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(and(eq(member.userId, userId), eq(organization.slug, slugOrId)))
.limit(1)
const row = rows[0] ?? (await getUserWorkspaceById(db, userId, slugOrId))
return row ? { ...row, href: `/dav/${encodeURIComponent(row.slug)}/` } : null
}
async function resolveWebDavPath(userId: string, rawPath: string): Promise<WebDavTarget> {
const parts = decodeDavPath(rawPath)
if (parts.length === 0) return { workspace: null, mountRoot: true, parent: '', name: '', matter: null }
const workspace = await getUserWorkspace(userId, parts[0])
if (!workspace) throw new WebDavPathError('Workspace not found', 404)
if (parts.length === 1) return { workspace, mountRoot: false, parent: '', name: '', matter: null }
const matterParts = parts.slice(1)
const name = matterParts.at(-1) ?? ''
const parent = matterParts.slice(0, -1).join('/')
const matter = await findMatterByPath(db, workspace.id, parent, name)
return { workspace, mountRoot: false, parent, name, matter }
}
return {
async listUserWorkspaces(userId) {
const rows = await db
.select({ id: organization.id, name: organization.name, slug: organization.slug })
.from(member)
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(eq(member.userId, userId))
.orderBy(asc(organization.name), asc(organization.slug))
return rows.map((row) => ({ ...row, href: `/dav/${encodeURIComponent(row.slug)}/` }))
},
async listChildren(orgId, parent) {
return db
.select()
.from(matters)
.where(and(eq(matters.orgId, orgId), eq(matters.parent, parent), eq(matters.status, ObjectStatus.ACTIVE)))
.orderBy(desc(matters.dirtype), asc(matters.name))
},
resolveWebDavPath,
async resolveExistingWebDavPath(userId, rawPath) {
const target = await resolveWebDavPath(userId, rawPath)
if (!target.matter) throw new WebDavPathError('Not found', 404)
return target
},
}
}
function decodeDavPath(rawPath: string): string[] {
if (!rawPath.startsWith('/')) throw new WebDavPathError('Invalid DAV path', 400)
if (rawPath.includes('//')) throw new WebDavPathError('Ambiguous DAV path', 400)
const withoutMount = rawPath.replace(/^\/dav(?:\/|$)/, '/')
const trimmed = withoutMount.replace(/^\/+|\/+$/g, '')
if (!trimmed) return []
return trimmed.split('/').map(decodeSegment)
}
function decodeSegment(segment: string): string {
if (!segment) throw new WebDavPathError('Ambiguous DAV path', 400)
if (/%2f|%5c/i.test(segment)) throw new WebDavPathError('Encoded path separators are not allowed', 400)
if (/%25(?:2e|2f|5c)/i.test(segment)) throw new WebDavPathError('Double-encoded path tricks are not allowed', 400)
let decoded: string
try {
decoded = decodeURIComponent(segment)
} catch {
throw new WebDavPathError('Invalid path encoding', 400)
}
if (!decoded || decoded === '.' || decoded === '..') throw new WebDavPathError('Invalid DAV path segment', 400)
if (decoded.includes('/') || decoded.includes('\\')) throw new WebDavPathError('Invalid DAV path segment', 400)
return decoded
}
async function findMatterByPath(db: Database, orgId: string, parent: string, name: string): Promise<Matter | null> {
const rows = await db
.select()
.from(matters)
.where(
and(
eq(matters.orgId, orgId),
eq(matters.parent, parent),
eq(matters.name, name),
eq(matters.status, ObjectStatus.ACTIVE),
),
)
.limit(1)
return rows[0] ?? null
}
async function getUserWorkspaceById(db: Database, userId: string, orgId: string) {
const rows = await db
.select({ id: organization.id, name: organization.name, slug: organization.slug })
.from(member)
.innerJoin(organization, eq(organization.id, member.organizationId))
.where(and(eq(member.userId, userId), eq(organization.id, orgId)))
.limit(1)
return rows[0] ?? null
}
@@ -0,0 +1,36 @@
import { env } from 'cloudflare:workers'
import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import { createCloudflarePlatform } from '../../platform/cloudflare'
import { createWebDavStateRepo } from './webdav-state'
function buildRepo() {
return createWebDavStateRepo(createCloudflarePlatform(env).db)
}
describe('[CF] WebDAV locks on D1', () => {
it('matches depth-infinity lock scopes without D1 dynamic LIKE expressions', async () => {
const repo = buildRepo()
const orgId = `org-${nanoid(8)}`
const parent = await repo.createLock({
orgId,
resourcePath: 'Folder',
owner: 'tester',
depth: 'infinity',
timeoutSeconds: 3600,
})
await repo.createLock({
orgId,
resourcePath: 'Other',
owner: 'tester',
depth: '0',
timeoutSeconds: 3600,
})
expect((await repo.activeLocks(orgId, 'Folder/child.txt')).map((lock) => lock.id)).toEqual([parent.id])
expect((await repo.conflictingLocks(orgId, 'Folder')).map((lock) => lock.id)).toEqual([parent.id])
expect(await repo.refreshLock(orgId, 'Folder/child.txt', parent.token, 3600)).toMatchObject({ id: parent.id })
expect(await repo.removeLock(orgId, 'Folder/child.txt', parent.token)).toBe(true)
expect(await repo.activeLocks(orgId, 'Folder/child.txt')).toEqual([])
})
})
+286
View File
@@ -0,0 +1,286 @@
import { and, eq, inArray, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { webdavDeadProperties, webdavLocks } from '../../db/schema'
import { type AtomicQuery, executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
import type { DavDeadProperty, DavLock, WebDavStateRepo } from '../../usecases/ports'
export function createWebDavStateRepo(db: Database): WebDavStateRepo {
return {
async listDeadPropertiesForResources(orgId, resourcePaths) {
const uniquePaths = [...new Set(resourcePaths)]
const result = new Map(uniquePaths.map((path) => [path, [] as DavDeadProperty[]]))
if (uniquePaths.length === 0) return result
const rows = await db
.select({
resourcePath: webdavDeadProperties.resourcePath,
namespace: webdavDeadProperties.namespace,
name: webdavDeadProperties.name,
value: webdavDeadProperties.value,
})
.from(webdavDeadProperties)
.where(and(eq(webdavDeadProperties.orgId, orgId), inArray(webdavDeadProperties.resourcePath, uniquePaths)))
for (const row of rows) {
result.get(row.resourcePath)?.push({ namespace: row.namespace, name: row.name, value: row.value })
}
return result
},
async applyDeadPropertyUpdate(orgId, resourcePath, operations) {
const now = new Date()
const queries: AtomicQuery[] = []
for (const operation of operations) {
if (operation.action === 'remove') {
queries.push(
db
.delete(webdavDeadProperties)
.where(
and(
eq(webdavDeadProperties.orgId, orgId),
eq(webdavDeadProperties.resourcePath, resourcePath),
eq(webdavDeadProperties.namespace, operation.property.namespace),
eq(webdavDeadProperties.name, operation.property.name),
),
),
)
continue
}
const property = operation.property
queries.push(
db
.insert(webdavDeadProperties)
.values({
id: nanoid(),
orgId,
resourcePath,
namespace: property.namespace,
name: property.name,
value: property.value,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
webdavDeadProperties.orgId,
webdavDeadProperties.resourcePath,
webdavDeadProperties.namespace,
webdavDeadProperties.name,
],
set: { value: property.value, updatedAt: now },
}),
)
}
await executeWriteTransaction(db, queries)
},
async copyDeadProperties(orgId, sourcePath, targetPath) {
const rows = await db
.select()
.from(webdavDeadProperties)
.where(and(eq(webdavDeadProperties.orgId, orgId), eq(webdavDeadProperties.resourcePath, sourcePath)))
if (rows.length === 0) return
const now = new Date()
await executeWriteTransaction(
db,
rows.map((row) =>
db
.insert(webdavDeadProperties)
.values({
id: nanoid(),
orgId,
resourcePath: targetPath,
namespace: row.namespace,
name: row.name,
value: row.value,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
webdavDeadProperties.orgId,
webdavDeadProperties.resourcePath,
webdavDeadProperties.namespace,
webdavDeadProperties.name,
],
set: { value: row.value, updatedAt: now },
}),
),
)
},
async deleteWebDavState(orgId, resourcePath) {
await executeWriteTransaction(db, [
db
.delete(webdavDeadProperties)
.where(
and(
eq(webdavDeadProperties.orgId, orgId),
or(
eq(webdavDeadProperties.resourcePath, resourcePath),
sql`${webdavDeadProperties.resourcePath} LIKE ${`${resourcePath}/%`}`,
),
),
),
db
.delete(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
or(
eq(webdavLocks.resourcePath, resourcePath),
sql`${webdavLocks.resourcePath} LIKE ${`${resourcePath}/%`}`,
),
),
),
])
},
async moveWebDavState(orgId, oldPath, newPath) {
const now = new Date()
await executeWriteTransaction(db, [
db
.update(webdavDeadProperties)
.set({
resourcePath: sql`CASE WHEN ${webdavDeadProperties.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavDeadProperties.resourcePath}, ${oldPath.length + 1}) END`,
updatedAt: now,
})
.where(
and(
eq(webdavDeadProperties.orgId, orgId),
or(
eq(webdavDeadProperties.resourcePath, oldPath),
sql`${webdavDeadProperties.resourcePath} LIKE ${`${oldPath}/%`}`,
),
),
),
db
.update(webdavLocks)
.set({
resourcePath: sql`CASE WHEN ${webdavLocks.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavLocks.resourcePath}, ${oldPath.length + 1}) END`,
updatedAt: now,
})
.where(
and(
eq(webdavLocks.orgId, orgId),
or(eq(webdavLocks.resourcePath, oldPath), sql`${webdavLocks.resourcePath} LIKE ${`${oldPath}/%`}`),
),
),
])
},
async activeLocks(orgId, resourcePath) {
await purgeExpiredLocks(db)
const now = Date.now()
const rows = await db
.select()
.from(webdavLocks)
.where(and(eq(webdavLocks.orgId, orgId), sql`${webdavLocks.expiresAt} > ${now}`))
return rows.filter((lock) => lockAppliesToResource(lock, resourcePath))
},
async activeLocksForResources(orgId, resourcePaths) {
const uniquePaths = [...new Set(resourcePaths)]
const result = new Map(uniquePaths.map((path) => [path, [] as DavLock[]]))
if (uniquePaths.length === 0) return result
await purgeExpiredLocks(db)
const now = Date.now()
const rows = await db
.select()
.from(webdavLocks)
.where(and(eq(webdavLocks.orgId, orgId), sql`${webdavLocks.expiresAt} > ${now}`))
for (const path of uniquePaths) {
result.set(
path,
rows.filter((lock) => lockAppliesToResource(lock, path)),
)
}
return result
},
async conflictingLocks(orgId, resourcePath) {
await purgeExpiredLocks(db)
const now = Date.now()
const rows = await db
.select()
.from(webdavLocks)
.where(and(eq(webdavLocks.orgId, orgId), sql`${webdavLocks.expiresAt} > ${now}`))
return rows.filter((lock) => lockConflictsWithResource(lock, resourcePath))
},
async createLock(input) {
const now = new Date()
const lock: DavLock = {
id: nanoid(),
token: `opaquelocktoken:${crypto.randomUUID()}`,
orgId: input.orgId,
resourcePath: input.resourcePath,
owner: input.owner,
depth: input.depth,
expiresAt: new Date(now.getTime() + input.timeoutSeconds * 1000),
createdAt: now,
updatedAt: now,
}
await db.insert(webdavLocks).values(lock)
return lock
},
async refreshLock(orgId, resourcePath, token, timeoutSeconds) {
await purgeExpiredLocks(db)
const now = new Date()
const expiresAt = new Date(now.getTime() + timeoutSeconds * 1000)
const rows = await db
.select()
.from(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
eq(webdavLocks.token, token),
sql`${webdavLocks.expiresAt} > ${now.getTime()}`,
),
)
const lock = rows.find((row) => lockAppliesToResource(row, resourcePath))
if (!lock) return null
await db.update(webdavLocks).set({ expiresAt, updatedAt: now }).where(eq(webdavLocks.id, lock.id))
return { ...lock, expiresAt, updatedAt: now }
},
async removeLock(orgId, resourcePath, token) {
await purgeExpiredLocks(db)
const rows = await db
.select()
.from(webdavLocks)
.where(
and(
eq(webdavLocks.orgId, orgId),
eq(webdavLocks.token, token),
sql`${webdavLocks.expiresAt} > ${Date.now()}`,
),
)
const lock = rows.find((row) => lockAppliesToResource(row, resourcePath))
if (!lock) return false
await db.delete(webdavLocks).where(eq(webdavLocks.id, lock.id))
return true
},
}
}
async function purgeExpiredLocks(db: Database): Promise<void> {
await db.delete(webdavLocks).where(sql`${webdavLocks.expiresAt} <= ${Date.now()}`)
}
function lockAppliesToResource(lock: DavLock, resourcePath: string): boolean {
if (lock.resourcePath === resourcePath) return true
if (lock.depth !== 'infinity') return false
if (lock.resourcePath === '') return true
return resourcePath.startsWith(`${lock.resourcePath}/`)
}
function lockConflictsWithResource(lock: DavLock, resourcePath: string): boolean {
if (lockAppliesToResource(lock, resourcePath)) return true
if (resourcePath === '') return lock.resourcePath !== ''
return lock.resourcePath.startsWith(`${resourcePath}/`)
}
@@ -1,49 +1,22 @@
import { and, eq, like, or } from 'drizzle-orm'
import { Zip, ZipDeflate, ZipPassThrough, type Zippable, zipSync } from 'fflate'
import { DirType } from '../../shared/constants'
import { matters } from '../db/schema'
import type { Database } from '../platform/interface'
import type { Matter } from './matter'
import { DirType } from '../../../shared/constants'
import { matters } from '../../db/schema'
import type { Database } from '../../platform/interface'
import type {
CollectCompressionPlanOptions,
CompressionPlan,
CompressionSourceDirectory,
CompressionSourceFile,
Matter,
ZipPlanRepo,
} from '../../usecases/ports'
import { ZIP_COMPRESS_LIMITS } from '../../usecases/ports'
export const ZIP_COMPRESS_LIMITS = {
totalInputBytes: 512 * 1024 * 1024,
singleFileBytes: 512 * 1024 * 1024,
fileCount: 1000,
directoryDepth: 10,
} as const
export interface CompressionSourceFile {
matter: Matter
archivePath: string
}
export interface CompressionSourceDirectory {
archivePath: string
}
export interface ZipSourceObject {
archivePath: string
bytes: Uint8Array
}
export interface ZipSourceStream {
archivePath: string
openStream: () => Promise<ReadableStream<Uint8Array>>
}
export interface CompressionPlan {
files: CompressionSourceFile[]
directories: CompressionSourceDirectory[]
inputBytes: number
outputName: string
targetFolder: string
}
export async function collectCompressionPlan(
async function collectCompressionPlan(
db: Database,
orgId: string,
matterIds: string[],
opts: { targetFolder?: string; outputName?: string } = {},
opts: CollectCompressionPlanOptions = {},
): Promise<CompressionPlan> {
const uniqueIds = [...new Set(matterIds)]
if (uniqueIds.length > ZIP_COMPRESS_LIMITS.fileCount) {
@@ -74,80 +47,6 @@ export async function collectCompressionPlan(
}
}
export function createZipArchive(
objects: ZipSourceObject[],
directories: CompressionSourceDirectory[] = [],
): Uint8Array {
const zippable: Zippable = {}
for (const directory of directories) zippable[`${directory.archivePath}/`] = new Uint8Array()
for (const object of objects) zippable[object.archivePath] = object.bytes
return zipSync(zippable, { level: 6 })
}
export function createZipArchiveStream(
sources: ZipSourceStream[],
directories: CompressionSourceDirectory[] = [],
): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
const zip = new Zip()
zip.ondata = (error, chunk, final) => {
if (error) {
controller.error(error)
return
}
if (chunk) controller.enqueue(new Uint8Array(chunk))
if (final) controller.close()
}
void streamZipEntries(zip, sources, directories, async () => {}).catch((error) => {
zip.terminate()
controller.error(error)
})
},
})
}
async function streamZipEntries(
zip: Zip,
sources: ZipSourceStream[],
directories: CompressionSourceDirectory[],
waitForWrites: () => Promise<void>,
): Promise<void> {
for (const directory of directories) {
const entry = new ZipPassThrough(`${directory.archivePath}/`)
zip.add(entry)
entry.push(new Uint8Array(), true)
await waitForWrites()
}
for (const source of sources) {
const entry = new ZipDeflate(source.archivePath, { level: 6 })
zip.add(entry)
await pushStreamToZipEntry(await source.openStream(), entry, waitForWrites)
}
zip.end()
}
async function pushStreamToZipEntry(
stream: ReadableStream<Uint8Array>,
entry: ZipDeflate,
waitForWrites: () => Promise<void>,
): Promise<void> {
const reader = stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) {
entry.push(new Uint8Array(), true)
await waitForWrites()
return
}
entry.push(value, false)
await waitForWrites()
}
}
function validateCompressionEntries(files: CompressionSourceFile[], directories: CompressionSourceDirectory[]): void {
let totalBytes = 0
const paths = new Set<string>()
@@ -238,3 +137,9 @@ function directoryDepth(path: string): number {
function buildPath(parent: string, name: string): string {
return parent ? `${parent}/${name}` : name
}
export function createZipPlanRepo(db: Database): ZipPlanRepo {
return {
collectCompressionPlan: (orgId, matterIds, opts) => collectCompressionPlan(db, orgId, matterIds, opts),
}
}
+42 -37
View File
@@ -3,6 +3,38 @@ import type { Context } from 'hono'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import type { Auth } from './auth'
import { createDeps } from './composition'
import { adminAnnouncements, announcements } from './http/announcements'
import { adminAudit } from './http/audit'
import { adminAuthProviders, publicAuthProviders } from './http/auth-providers'
import backgroundJobs from './http/background-jobs'
import { brandingAdmin, publicBranding } from './http/branding'
import { cloudStore, cloudStoreWebhooks } from './http/cloud-store'
import downloadTasks from './http/download-tasks'
import downloaders, { downloaderSelfRoute } from './http/downloaders'
import emailConfig from './http/email-config'
import { events } from './http/events'
import ihost from './http/ihost'
import ihostConfig from './http/ihost-config'
import internal from './http/internal'
import { adminInviteCodes, publicInviteCodes } from './http/invite-codes'
import licensing from './http/licensing'
import licensingAdmin from './http/licensing-admin'
import { me } from './http/me'
import { notifications } from './http/notifications'
import objects from './http/objects'
import profile from './http/profile'
import { adminQuotas, userQuotas } from './http/quotas'
import redirect from './http/redirect'
import { authedShares, publicShares } from './http/shares'
import { adminSiteInvitations, publicSiteInvitations } from './http/site-invitations'
import storages from './http/storages'
import system from './http/system'
import { publicTeams, teams } from './http/teams'
import { adminTeams } from './http/teams-admin'
import trash from './http/trash'
import users from './http/users'
import webdav from './http/webdav'
import { formatError } from './lib/errors'
import { authMiddleware } from './middleware/auth'
import { imageHostingDomain } from './middleware/image-hosting-domain'
@@ -11,55 +43,28 @@ import type { Env } from './middleware/platform'
import { platformMiddleware } from './middleware/platform'
import { downloaderOpenAPIDocument } from './openapi/downloader'
import type { Platform } from './platform/interface'
import { adminAnnouncements, announcements } from './routes/announcements'
import { adminAudit } from './routes/audit'
import { adminAuthProviders, publicAuthProviders } from './routes/auth-providers'
import backgroundJobs from './routes/background-jobs'
import { brandingAdmin, publicBranding } from './routes/branding'
import { cloudStore, cloudStoreWebhooks } from './routes/cloud-store'
import downloadTasks from './routes/download-tasks'
import downloaders, { downloaderSelfRoute } from './routes/downloaders'
import emailConfig from './routes/email-config'
import { events } from './routes/events'
import ihost from './routes/ihost'
import ihostConfig from './routes/ihost-config'
import internal from './routes/internal'
import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes'
import licensing from './routes/licensing'
import licensingAdmin from './routes/licensing-admin'
import { me } from './routes/me'
import { notifications } from './routes/notifications'
import objects from './routes/objects'
import profile from './routes/profile'
import { adminQuotas, userQuotas } from './routes/quotas'
import redirect from './routes/redirect'
import { authedShares, publicShares } from './routes/shares'
import { adminSiteInvitations, publicSiteInvitations } from './routes/site-invitations'
import storages from './routes/storages'
import system from './routes/system'
import { publicTeams, teams } from './routes/teams'
import { adminTeams } from './routes/teams-admin'
import trash from './routes/trash'
import users from './routes/users'
import webdav from './routes/webdav'
import { getDeployPlatform } from './runtime-platform'
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/instance-telemetry'
import { ensureSitePublicOrigin } from './services/site-public-origin'
import type { Deps } from './usecases/deps'
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './usecases/instance-telemetry'
import { ensureSitePublicOrigin } from './usecases/site-public-origin'
export function createApp(platform: Platform, auth: Auth) {
export function createApp(platform: Platform, auth: Auth, deps: Deps = createDeps(platform)) {
const app = new Hono<Env>()
const corsOrigins = getCorsOrigins(platform)
app.use('/*', platformMiddleware(platform, auth))
app.use('/*', async (c, next) => {
const result = await ensureSitePublicOrigin(platform.db, c.req.url).catch((err) => {
c.set('deps', deps)
await next()
})
app.use('/*', async (c, next) => {
const result = await ensureSitePublicOrigin(deps, c.req.url).catch((err) => {
console.error(`site.public_origin.detect.error code=${formatError(err)}`)
return { origin: null, created: false }
})
if (result.created && result.origin && shouldReportInitialTelemetry(c.req.url)) {
const task = reportInstanceTelemetry({
db: platform.db,
const task = reportInstanceTelemetry(deps, {
config: {
siteUrl: result.origin,
allowIp: envAllowsIp(platform.getEnv('ZPAN_TELEMETRY_ALLOW_IP')),
+9 -9
View File
@@ -1,12 +1,12 @@
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { createInviteRepo } from './adapters/repos/invite.js'
import { createSiteInvitationRepo } from './adapters/repos/site-invitations.js'
import { createApp } from './app.js'
import { createAuth } from './auth.js'
import * as authSchema from './db/auth-schema.js'
import * as schema from './db/schema.js'
import { inviteCodes, siteInvitations } from './db/schema.js'
import { generateInviteCodes } from './services/invite.js'
import { createSiteInvitation } from './services/site-invitations.js'
import { createTestApp, seedProLicense } from './test/setup.js'
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
@@ -126,7 +126,7 @@ describe('registration gate — closed mode', () => {
.from(authSchema.user)
.where(eq(authSchema.user.email, 'first@example.com'))
.limit(1)
const invitation = await createSiteInvitation(ctx.db, admin.id, 'invited@example.com')
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token })
@@ -142,7 +142,7 @@ describe('registration gate — closed mode', () => {
.from(authSchema.user)
.where(eq(authSchema.user.email, 'first@example.com'))
.limit(1)
const invitation = await createSiteInvitation(ctx.db, admin.id, 'invited@example.com')
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token })
const body = (await res.json()) as { user: { id: string } }
@@ -165,7 +165,7 @@ describe('registration gate — closed mode', () => {
.from(authSchema.user)
.where(eq(authSchema.user.email, 'first@example.com'))
.limit(1)
const invitation = await createSiteInvitation(ctx.db, admin.id, 'invited@example.com')
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
const res = await signUp(ctx, 'other@example.com', { siteInvitationToken: invitation.token })
@@ -203,7 +203,7 @@ describe('registration gate — invite_only mode', () => {
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const pastDate = new Date(Date.now() - 1000)
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1, pastDate)
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1, pastDate)
const res = await signUp(ctx, 'expired@example.com', { inviteCode: codeRow.code })
expect(res.status).not.toBe(200)
})
@@ -212,7 +212,7 @@ describe('registration gate — invite_only mode', () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1)
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
const res = await signUp(ctx, 'invited@example.com', { inviteCode: codeRow.code })
expect(res.status).toBe(200)
})
@@ -221,7 +221,7 @@ describe('registration gate — invite_only mode', () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1)
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
const res = await signUp(ctx, 'invited2@example.com', { inviteCode: codeRow.code })
const body = (await res.json()) as { user: { id: string } }
const [row] = await ctx.db.select().from(inviteCodes).where(eq(inviteCodes.code, codeRow.code))
@@ -233,7 +233,7 @@ describe('registration gate — invite_only mode', () => {
const ctx = await createTestApp()
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
await signUp(ctx, 'first@example.com')
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1)
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
await signUp(ctx, 'user1@example.com', { inviteCode: codeRow.code })
const res = await signUp(ctx, 'user2@example.com', { inviteCode: codeRow.code })
expect(res.status).not.toBe(200)
+82 -34
View File
@@ -1,6 +1,7 @@
import { apiKey } from '@better-auth/api-key'
import { APIError, type BetterAuthPlugin, betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import type { CaptchaOptions } from 'better-auth/plugins'
import { admin, bearer, captcha, deviceAuthorization, organization, username } from 'better-auth/plugins'
import { genericOAuth } from 'better-auth/plugins/generic-oauth'
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
@@ -20,23 +21,27 @@ import {
type OAuthProviderConfig,
parseProviderConfig,
} from '../shared/oauth-providers'
import { createEmailGateway } from './adapters/gateways/email'
import { createActivityRepo } from './adapters/repos/activity'
import { createInviteRepo } from './adapters/repos/invite'
import { createLicenseBindingRepo } from './adapters/repos/license-binding'
import { createMemberCountRepo } from './adapters/repos/member-count'
import { createNotificationRepo } from './adapters/repos/notification'
import { createOrgRepo } from './adapters/repos/org'
import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
import { createSystemOptionsRepo } from './adapters/repos/system-options'
import * as authSchema from './db/auth-schema'
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
import { executeWriteTransaction } from './db/transaction'
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig } from './domain/captcha'
import { currentTrafficPeriod } from './domain/quota'
import { isLocalNetworkOrigin } from './lib/local-origin'
import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password'
import { createDbProxy, createPlatformProxy } from './platform/context'
import type { Database, Platform } from './platform/interface'
import { recordActivity } from './services/activity'
import { CAPTCHA_AUTH_ENDPOINTS, loadCaptchaConfig, toBetterAuthCaptchaOptions } from './services/captcha'
import { executeWriteTransaction } from './services/db-transaction'
import { currentTrafficPeriod } from './services/effective-quota'
import { isEmailConfigured, sendEmail } from './services/email'
import { redeemInviteCode, validateInviteCode } from './services/invite'
import { createNotification } from './services/notification'
import { findPersonalOrg } from './services/org'
import { getEffectiveSignupMode } from './services/signup-mode-guard'
import { acceptSiteInvitation, validateSiteInvitation } from './services/site-invitations'
import { checkTeamLimit } from './services/team-count-guard'
import { loadCaptchaConfig } from './usecases/captcha'
import { getEffectiveSignupMode } from './usecases/signup-mode'
import { checkTeamLimit } from './usecases/team-count'
// better-auth's default password hasher is pure-JS scrypt from @noble/hashes,
// which blows past Cloudflare Workers' CPU budget and triggers error 1102.
@@ -83,6 +88,27 @@ async function loadProviderConfigs(db: Database): Promise<ProviderConfigs> {
return configs
}
// Maps stored captcha config to the better-auth captcha plugin options. Lives
// here because better-auth's CaptchaOptions type is delivery-framework-specific
// and may not leak into the framework-free usecases/ layer.
export function toBetterAuthCaptchaOptions(config: CaptchaConfig): CaptchaOptions {
const base = {
provider: config.provider,
secretKey: config.secretKey,
endpoints: [...CAPTCHA_AUTH_ENDPOINTS],
}
if (config.provider === 'google-recaptcha') {
return config.minScore === undefined ? base : { ...base, minScore: config.minScore }
}
if (config.provider === 'hcaptcha' || config.provider === 'captchafox') {
return { ...base, siteKey: config.siteKey }
}
return base
}
function dynamicCaptcha(db: Database): BetterAuthPlugin {
return {
id: 'dynamic-captcha',
@@ -91,7 +117,7 @@ function dynamicCaptcha(db: Database): BetterAuthPlugin {
// for everything else (notably get-session, the hottest auth route).
const path = new URL(request.url).pathname
if (!CAPTCHA_AUTH_ENDPOINTS.some((endpoint) => path.endsWith(endpoint))) return
const config = await loadCaptchaConfig(db)
const config = await loadCaptchaConfig({ systemOptions: createSystemOptionsRepo(db) })
if (!config) return
const plugin = captcha(toBetterAuthCaptchaOptions(config))
return plugin.onRequest?.(request, ctx)
@@ -161,7 +187,12 @@ export async function createAuth(
const dbProxy = platformProxy ? platformProxy.db : createDbProxy(rawDb)
const db = dbProxy
const source = platformProxy || dbProxy
// The email gateway needs a Platform for the Cloudflare EMAIL binding. On the
// bare-Database path (tests, Node fallbacks) there is no platform, so wrap the
// db proxy in a binding-free Platform — matching the previous behaviour where
// a Database source had no CF binding available.
const authPlatform: Platform = platformProxy ?? { db: dbProxy, getEnv: () => undefined, getBinding: () => undefined }
const email = createEmailGateway(createSystemOptionsRepo(db))
const providerConfigs = await loadProviderConfigs(rawDb)
const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
@@ -189,8 +220,8 @@ export async function createAuth(
verify: authVerifyPassword,
},
sendResetPassword: async ({ user, url }) => {
if (!(await isEmailConfigured(source))) return
await sendEmail(source, {
if (!(await email.isConfigured(authPlatform))) return
await email.send(authPlatform, {
to: user.email,
subject: 'Reset your password - ZPan',
html: buildResetPasswordEmailHtml(url),
@@ -199,8 +230,8 @@ export async function createAuth(
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
if (!(await isEmailConfigured(source))) return
await sendEmail(source, {
if (!(await email.isConfigured(authPlatform))) return
await email.send(authPlatform, {
to: user.email,
subject: 'Verify your email - ZPan',
html: buildVerificationEmailHtml(url),
@@ -228,8 +259,8 @@ export async function createAuth(
viewer: memberAc,
},
sendInvitationEmail: async (data) => {
if (!(await isEmailConfigured(source))) return
await sendEmail(source, {
if (!(await email.isConfigured(authPlatform))) return
await email.send(authPlatform, {
to: data.email,
subject: `You've been invited to join ${data.organization.name} - ZPan`,
html: buildInvitationEmailHtml(data),
@@ -237,7 +268,14 @@ export async function createAuth(
},
organizationHooks: {
beforeCreateOrganization: async ({ user }) => {
const { allowed, count: current_count, limit } = await checkTeamLimit(db, user.id)
const {
allowed,
count: current_count,
limit,
} = await checkTeamLimit(
{ memberCount: createMemberCountRepo(db), licenseBinding: createLicenseBindingRepo(db) },
user.id,
)
if (!allowed) {
throw new APIError('PAYMENT_REQUIRED', {
message:
@@ -254,7 +292,7 @@ export async function createAuth(
await createOrgQuota(db, organization.id, new Date(), isTeam)
},
afterAcceptInvitation: async ({ member, user, organization }) => {
await recordActivity(db, {
await createActivityRepo(db).record({
orgId: organization.id,
userId: user.id,
action: 'team_member_join',
@@ -263,7 +301,7 @@ export async function createAuth(
targetName: organization.name,
metadata: { role: member.role },
})
await createNotification(db, {
await createNotificationRepo(db).create({
userId: user.id,
type: 'team_join',
title: `You joined ${organization.name}`,
@@ -276,7 +314,7 @@ export async function createAuth(
afterRemoveMember: async ({ member, organization }) => {
// Better Auth does not expose the actor (initiator) in this hook;
// member.userId is the removed user — used here as the attributed userId.
await recordActivity(db, {
await createActivityRepo(db).record({
orgId: organization.id,
userId: member.userId,
action: 'team_member_remove',
@@ -289,7 +327,7 @@ export async function createAuth(
afterUpdateMemberRole: async ({ member, previousRole, organization }) => {
// Better Auth does not expose the actor in this hook;
// member.userId is the user whose role changed.
await recordActivity(db, {
await createActivityRepo(db).record({
orgId: organization.id,
userId: member.userId,
action: 'team_member_role_update',
@@ -301,7 +339,7 @@ export async function createAuth(
},
afterUpdateOrganization: async ({ organization, user }) => {
if (!organization?.id || !user?.id) return
await recordActivity(db, {
await createActivityRepo(db).record({
orgId: organization.id,
userId: user.id,
action: 'team_settings_update',
@@ -311,7 +349,7 @@ export async function createAuth(
})
},
afterDeleteOrganization: async ({ organization, user }) => {
await recordActivity(db, {
await createActivityRepo(db).record({
orgId: organization.id,
userId: user.id,
action: 'team_delete',
@@ -386,14 +424,17 @@ export async function createAuth(
// Registration gate: skip for the very first user so bootstrap works
if (!firstUser) {
const mode = await getEffectiveSignupMode(db)
const mode = await getEffectiveSignupMode({
systemOptions: createSystemOptionsRepo(db),
licenseBinding: createLicenseBindingRepo(db),
})
const email = String(user.email ?? '')
const siteInvitationToken = (context?.body as { siteInvitationToken?: string })?.siteInvitationToken
if (mode === SignupMode.CLOSED) {
if (!siteInvitationToken) {
throw new Error('An invitation is required to register')
}
const validation = await validateSiteInvitation(db, siteInvitationToken, email)
const validation = await createSiteInvitationRepo(db).validateSiteInvitation(siteInvitationToken, email)
if (!validation.valid) {
throw new Error(validation.error ?? 'Invalid invitation')
}
@@ -403,7 +444,7 @@ export async function createAuth(
if (!inviteCode) {
throw new Error('An invite code is required to register')
}
const validation = await validateInviteCode(db, inviteCode)
const validation = await createInviteRepo(db).validate(inviteCode)
if (!validation.valid) {
throw new Error(validation.error ?? 'Invalid invite code')
}
@@ -429,17 +470,24 @@ export async function createAuth(
},
after: async (user, context) => {
// Redeem invite code after user is created (user.id is now available)
const mode = await getEffectiveSignupMode(db)
const mode = await getEffectiveSignupMode({
systemOptions: createSystemOptionsRepo(db),
licenseBinding: createLicenseBindingRepo(db),
})
if (mode === SignupMode.INVITE_ONLY) {
const inviteCode = (context?.body as { inviteCode?: string })?.inviteCode
if (inviteCode) {
await redeemInviteCode(db, inviteCode, user.id)
await createInviteRepo(db).redeem(inviteCode, user.id)
}
}
const siteInvitationToken = (context?.body as { siteInvitationToken?: string })?.siteInvitationToken
if (siteInvitationToken) {
const result = await acceptSiteInvitation(db, siteInvitationToken, user.email, user.id)
const result = await createSiteInvitationRepo(db).acceptSiteInvitation(
siteInvitationToken,
user.email,
user.id,
)
if (result !== 'ok' && result !== 'accepted') {
throw new Error(`Failed to redeem site invitation: ${result}`)
}
@@ -450,7 +498,7 @@ export async function createAuth(
// when autoSignIn is enabled the org is actually created by
// session.create.before (which runs earlier, inside the txn).
// The idempotent check ensures no duplicate is created.
const existing = await findPersonalOrg(db, user.id)
const existing = await createOrgRepo(db).findPersonalOrg(user.id)
if (!existing) {
await createPersonalOrg(db, user)
}
@@ -461,7 +509,7 @@ export async function createAuth(
create: {
before: async (session) => {
// Look up existing personal org (returning users)
let orgId = await findPersonalOrg(db, session.userId)
let orgId = await createOrgRepo(db).findPersonalOrg(session.userId)
// For new sign-ups the org doesn't exist yet — create it now.
// This runs inside the sign-up transaction, after the user row
+104
View File
@@ -0,0 +1,104 @@
// The composition root. createDeps wires concrete adapters into the Deps object
// the rest of the server consumes. This is the ONLY place adapters are
// constructed. Keep it a cheap, request-free factory so the scheduled/queue
// entrypoints can reuse it; request-bound capabilities are passed to usecases as
// function parameters, never stored here.
import { createArchiveJobsGateway } from './adapters/gateways/archive-jobs'
import { createEmailGateway } from './adapters/gateways/email'
import { createImageUploadGateway } from './adapters/gateways/image-upload'
import { createLicensingCloudGateway } from './adapters/gateways/licensing-cloud'
import { S3Service } from './adapters/gateways/s3'
import { createZipGateway } from './adapters/gateways/zip'
import { createCfClient } from './adapters/providers/cf-custom-hostnames'
import { createChangelogProvider } from './adapters/providers/changelog'
import { createActivityRepo } from './adapters/repos/activity'
import { createAnnouncementRepo } from './adapters/repos/announcement'
import { createApiKeyGateway } from './adapters/repos/api-keys'
import { createArchiveTargetFolderRepo } from './adapters/repos/archive-target-folder'
import { createBackgroundJobRepo } from './adapters/repos/background-job'
import { createCloudStoreRepo } from './adapters/repos/cloud-store'
import { createCloudTrafficReportRepo } from './adapters/repos/cloud-traffic-report'
import { createDownloadTaskRepo } from './adapters/repos/download-task'
import { createDownloadTokenGateway } from './adapters/repos/download-tokens'
import { createDownloaderRepo } from './adapters/repos/downloader'
import { createImageHostingRepo } from './adapters/repos/image-hosting'
import { createImageHostingConfigRepo } from './adapters/repos/image-hosting-config'
import { createInstanceRepo } from './adapters/repos/instance'
import { createInviteRepo } from './adapters/repos/invite'
import { createLicenseBindingRepo } from './adapters/repos/license-binding'
import { createMatterRepo } from './adapters/repos/matter'
import { createMemberCountRepo } from './adapters/repos/member-count'
import { createNotificationRepo } from './adapters/repos/notification'
import { createObjectUploadSessionRepo } from './adapters/repos/object-upload-session'
import { createOrgRepo } from './adapters/repos/org'
import { createProfileRepo } from './adapters/repos/profile'
import { createQuotaRepo } from './adapters/repos/quota'
import { createRemoteDownloadUsageRepo } from './adapters/repos/remote-download-usage'
import { createShareRepo } from './adapters/repos/share'
import { createShareNotificationRepo } from './adapters/repos/share-notification'
import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
import { createStorageRepo } from './adapters/repos/storage'
import { createStorageUsageRepo } from './adapters/repos/storage-usage'
import { createSystemOptionsRepo } from './adapters/repos/system-options'
import { createTeamRepo } from './adapters/repos/team'
import { createTeamInviteRepo } from './adapters/repos/team-invite'
import { createUserAdminRepo } from './adapters/repos/user-admin'
import { createWebDavPathRepo } from './adapters/repos/webdav-path'
import { createWebDavStateRepo } from './adapters/repos/webdav-state'
import { createZipPlanRepo } from './adapters/repos/zip'
import type { Platform } from './platform/interface'
import type { Deps } from './usecases/deps'
export function createDeps(platform: Platform): Deps {
const { db } = platform
// Shared stateless instances reused by multiple ports below.
const s3 = new S3Service()
const storages = createStorageRepo(db)
const systemOptions = createSystemOptionsRepo(db)
return {
activity: createActivityRepo(db),
announcements: createAnnouncementRepo(db),
apiKeys: createApiKeyGateway(),
archiveJobs: createArchiveJobsGateway(platform),
archiveTargetFolders: createArchiveTargetFolderRepo(db),
backgroundJobs: createBackgroundJobRepo(db),
cfHostnames: createCfClient((key) => platform.getEnv(key)),
changelog: createChangelogProvider(),
cloudStore: createCloudStoreRepo(db),
cloudTrafficReports: createCloudTrafficReportRepo(db),
downloaders: createDownloaderRepo(db),
downloadTasks: createDownloadTaskRepo(db),
downloadTokens: createDownloadTokenGateway(),
email: createEmailGateway(systemOptions),
invites: createInviteRepo(db),
imageHostingConfigs: createImageHostingConfigRepo(db),
imageHosting: createImageHostingRepo(db),
imageUpload: createImageUploadGateway(s3, storages),
instance: createInstanceRepo(db),
licenseBinding: createLicenseBindingRepo(db),
licensingCloud: createLicensingCloudGateway(),
matter: createMatterRepo(db),
memberCount: createMemberCountRepo(db),
notifications: createNotificationRepo(db),
objectUploadSessions: createObjectUploadSessionRepo(db),
org: createOrgRepo(db),
profiles: createProfileRepo(db),
quota: createQuotaRepo(db),
remoteDownloadUsage: createRemoteDownloadUsageRepo(db),
s3,
shareNotifications: createShareNotificationRepo(db),
share: createShareRepo(db),
siteInvitations: createSiteInvitationRepo(db),
storages,
storageUsage: createStorageUsageRepo(db),
systemOptions,
teams: createTeamRepo(db),
teamInvites: createTeamInviteRepo(db),
userAdmin: createUserAdminRepo(db),
webdavPath: createWebDavPathRepo(db),
webdavState: createWebDavStateRepo(db),
zip: createZipGateway(),
zipPlan: createZipPlanRepo(db),
}
}
+4
View File
@@ -0,0 +1,4 @@
export function buildBreadcrumb(dir: string): string[] {
if (!dir) return []
return dir.split('/')
}
@@ -6,7 +6,8 @@ import {
CAPTCHA_SECRET_OPTION_KEY,
CAPTCHA_SITE_KEY_KEY,
} from '../../shared/captcha.js'
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig, readCaptchaConfig, toBetterAuthCaptchaOptions } from './captcha.js'
import { toBetterAuthCaptchaOptions } from '../auth.js'
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig, readCaptchaConfig } from './captcha.js'
const COMPLETE_CONFIG = {
[CAPTCHA_ENABLED_KEY]: 'true',
@@ -14,7 +15,7 @@ const COMPLETE_CONFIG = {
[CAPTCHA_SECRET_OPTION_KEY]: 'secret-key',
}
describe('captcha service', () => {
describe('captcha config', () => {
it('treats absent settings as disabled', () => {
expect(readCaptchaConfig({})).toBeNull()
})
+52
View File
@@ -0,0 +1,52 @@
import {
CAPTCHA_ENABLED_KEY,
CAPTCHA_MIN_SCORE_KEY,
CAPTCHA_PROVIDER_KEY,
CAPTCHA_PROVIDERS,
CAPTCHA_SECRET_OPTION_KEY,
CAPTCHA_SITE_KEY_KEY,
type CaptchaProvider,
DEFAULT_CAPTCHA_PROVIDER,
} from '@shared/captcha'
export const CAPTCHA_AUTH_ENDPOINTS = ['/sign-up/email', '/sign-in/email', '/sign-in/username'] as const
export type CaptchaConfig = {
enabled: boolean
provider: CaptchaProvider
siteKey: string
secretKey: string
minScore?: number
}
export type CaptchaOptionValues = Partial<Record<string, string>>
export function isCaptchaProvider(value: string): value is CaptchaProvider {
return (CAPTCHA_PROVIDERS as readonly string[]).includes(value)
}
export function readCaptchaConfig(options: CaptchaOptionValues): CaptchaConfig | null {
if (options[CAPTCHA_ENABLED_KEY] !== 'true') return null
const provider = options[CAPTCHA_PROVIDER_KEY] ?? DEFAULT_CAPTCHA_PROVIDER
if (!isCaptchaProvider(provider)) throw new Error('Captcha provider is invalid')
const siteKey = options[CAPTCHA_SITE_KEY_KEY]?.trim() ?? ''
if (!siteKey) throw new Error('Captcha site key is required before enabling captcha')
const secretKey = options[CAPTCHA_SECRET_OPTION_KEY]?.trim() ?? ''
if (!secretKey) throw new Error('Captcha secret key is required before enabling captcha')
const minScore = readMinScore(options[CAPTCHA_MIN_SCORE_KEY])
return { enabled: true, provider, siteKey, secretKey, minScore }
}
function readMinScore(raw: string | undefined): number | undefined {
const value = raw?.trim()
if (!value) return undefined
const score = Number(value)
if (!Number.isFinite(score) || score < 0 || score > 1) {
throw new Error('Captcha minimum score must be between 0 and 1')
}
return score
}
+43
View File
@@ -0,0 +1,43 @@
const PATH_PATTERN = /^[a-zA-Z0-9._/-]+$/
const MAX_DEPTH = 5
const MAX_PATH_LENGTH = 256
export type PathValidationError = { error: 'invalid path'; detail: string }
export function validatePath(path: string): PathValidationError | null {
if (!PATH_PATTERN.test(path)) {
return { error: 'invalid path', detail: 'path contains invalid characters' }
}
if (path.startsWith('/')) {
return { error: 'invalid path', detail: 'path must not start with /' }
}
if (path.endsWith('/')) {
return { error: 'invalid path', detail: 'path must not end with /' }
}
if (path.includes('..')) {
return { error: 'invalid path', detail: 'path must not contain ..' }
}
if (path.includes('//')) {
return { error: 'invalid path', detail: 'path must not contain //' }
}
if (path.length > MAX_PATH_LENGTH) {
return { error: 'invalid path', detail: `path exceeds ${MAX_PATH_LENGTH} characters` }
}
const depth = path.split('/').length
if (depth > MAX_DEPTH) {
return { error: 'invalid path', detail: `path depth exceeds ${MAX_DEPTH} segments` }
}
return null
}
export interface ImageUrlConfig {
customDomain: string | null
domainVerifiedAt: Date | null
}
export function buildImageUrl(config: ImageUrlConfig | null, path: string, tokenUrl: string): string {
if (config?.customDomain && config.domainVerifiedAt) {
return `https://${config.customDomain}/${path}`
}
return tokenUrl
}
@@ -1,6 +1,6 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest'
import { getTrustedPublicKeys, PUBLIC_KEYS, registerEnvPublicKeys } from './public-keys'
import { getTrustedPublicKeys, PUBLIC_KEYS, registerEnvPublicKeys } from './license-keys'
describe('public keys', () => {
afterEach(() => {
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { BindingState } from '../../shared/types'
import { hasFeature } from './has-feature'
import { hasFeature } from './licensing'
describe('hasFeature', () => {
it('returns false when state is null', () => {
+14
View File
@@ -0,0 +1,14 @@
import { PRO_GATE_KEYS } from '@shared/feature-registry'
import type { BindingState, LicenseFeature } from '@shared/types'
const BUSINESS_ONLY_FEATURES = new Set<LicenseFeature>(['quota_store', 'site_announcements'])
export function effectiveFeatures(edition: BindingState['edition']): LicenseFeature[] {
if (edition === 'pro') return PRO_GATE_KEYS.filter((feature) => !BUSINESS_ONLY_FEATURES.has(feature))
if (edition === 'business') return [...PRO_GATE_KEYS]
return []
}
export function hasFeature(feature: LicenseFeature, state: BindingState | null): boolean {
return Boolean(feature && state?.bound && state.active && effectiveFeatures(state.edition).includes(feature))
}
+8
View File
@@ -0,0 +1,8 @@
// Windows-style auto-rename: "report.pdf" → "report (1).pdf", then " (2)", ...
// For folders or dot-prefixed names the suffix is appended to the whole name.
// Pure: no I/O, the repo drives the availability search around this.
export function suggestRenamed(name: string, index: number): string {
const dot = name.lastIndexOf('.')
if (dot <= 0) return `${name} (${index})`
return `${name.slice(0, dot)} (${index})${name.slice(dot)}`
}
+5
View File
@@ -0,0 +1,5 @@
// The org's traffic accounting period, `YYYY-MM` in UTC. Pure.
export function currentTrafficPeriod(now = new Date()): string {
const month = String(now.getUTCMonth() + 1).padStart(2, '0')
return `${now.getUTCFullYear()}-${month}`
}
+5
View File
@@ -0,0 +1,5 @@
// Pure share access rules. No I/O, no frameworks.
export function isAccessibleByUser(recipients: Array<{ recipientUserId: string | null }>, userId: string): boolean {
return recipients.some((r) => r.recipientUserId === userId)
}
+23
View File
@@ -0,0 +1,23 @@
export const SITE_PUBLIC_ORIGIN_KEY = 'site_public_origin'
export function originFromRequestUrl(requestUrl: string): string | null {
try {
const url = new URL(requestUrl)
return normalizePublicOrigin(url.origin)
} catch {
return null
}
}
export function normalizePublicOrigin(value: string | undefined | null): string | null {
const input = value?.trim()
if (!input) return null
try {
const url = new URL(input)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.origin
} catch {
return null
}
}
@@ -1,10 +1,17 @@
import { DirType } from '../../shared/constants'
import type { Matter } from './matter'
import type { WebDavWorkspace } from './webdav-path'
import { matterHref, workspaceHref } from './webdav-path'
import type { DavDeadProperty, DavLock, DavPropertyName } from './webdav-state'
import { DirType } from '@shared/constants'
import {
DAV_NAMESPACE,
type DavDeadProperty,
type DavLock,
type DavPropertyName,
davEtag,
matterHref,
type WebDavMatter,
type WebDavWorkspace,
workspaceHref,
} from './webdav'
export const DAV_NAMESPACE = 'DAV:'
export { DAV_NAMESPACE, davEtag }
export interface DavEntry {
href: string
@@ -71,7 +78,7 @@ export function mountRootEntry(): DavEntry {
export function matterEntry(
workspace: WebDavWorkspace,
matter: Matter,
matter: WebDavMatter,
deadProperties: DavDeadProperty[],
locks: DavLock[],
): DavEntry {
@@ -183,10 +190,6 @@ export function parseLockInfoXml(body: string): LockInfoRequest {
return { owner }
}
export function davEtag(id: string, size: number, updatedAt: Date): string {
return `"${id}-${size}-${updatedAt.getTime()}"`
}
export function xmlResponse(body: string, status: number, headers?: Record<string, string>): Response {
return new Response(body, {
status,
+59
View File
@@ -0,0 +1,59 @@
export const DAV_NAMESPACE = 'DAV:'
export interface DavPropertyName {
namespace: string
name: string
}
export interface DavDeadProperty extends DavPropertyName {
value: string
}
export interface DavLock {
id: string
token: string
orgId: string
resourcePath: string
owner: string
depth: string
expiresAt: Date
createdAt: Date
updatedAt: Date
}
export interface WebDavWorkspace {
id: string
name: string
slug: string
href: string
}
// The matter fields the WebDAV href/etag/entry helpers read. A full matter row
// is structurally assignable, so http handlers pass their rows directly.
export interface WebDavMatter {
id: string
name: string
parent: string
type: string
size: number | null
dirtype: number | null
createdAt: Date
updatedAt: Date
}
export function joinMatterPath(parent: string, name: string): string {
return parent ? `${parent}/${name}` : name
}
export function matterHref(workspace: WebDavWorkspace, matter: Pick<WebDavMatter, 'parent' | 'name'>): string {
const path = joinMatterPath(matter.parent, matter.name)
return `/dav/${encodeURIComponent(workspace.slug)}/${path.split('/').map(encodeURIComponent).join('/')}`
}
export function workspaceHref(workspace: WebDavWorkspace): string {
return `/dav/${encodeURIComponent(workspace.slug)}/`
}
export function davEtag(id: string, size: number, updatedAt: Date): string {
return `"${id}-${size}-${updatedAt.getTime()}"`
}
+19 -21
View File
@@ -5,18 +5,19 @@ import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
import { resolveAppCommit, resolveAppVersion } from '../scripts/app-version.mjs'
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
import { createQuotaRepo } from './adapters/repos/quota'
import { createBootstrap } from './bootstrap'
import { buildCloudInstanceInfo, runtimeInfo } from './licensing/instance-info'
import { createDeps } from './composition'
import { createLibsqlPlatform } from './platform/libsql'
import { createNodePlatform } from './platform/node'
import { type DeployPlatform, setDeployPlatform } from './runtime-platform'
import { syncPendingCloudTrafficReports } from './services/cloud-traffic-metering'
import { resetExpiredTrafficQuotas } from './services/effective-quota'
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/instance-telemetry'
import { runLicensingRefresh } from './services/licensing-refresh-runner'
import { syncPendingRemoteDownloadUsageReports } from './services/remote-download-usage'
import { getSitePublicOrigin } from './services/site-public-origin'
import { purgeExpiredTrash, resolveTrashRetentionDays } from './services/trash-retention'
import { syncPendingCloudTrafficReports } from './usecases/cloud-traffic-metering'
import { buildCloudInstanceInfo, runtimeInfo } from './usecases/instance-info'
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './usecases/instance-telemetry'
import { runLicensingRefresh } from './usecases/licensing-refresh-runner'
import { syncPendingRemoteDownloadUsageReports } from './usecases/remote-download-usage'
import { getSitePublicOrigin } from './usecases/site-public-origin'
import { purgeExpiredTrash, resolveTrashRetentionDays } from './usecases/trash-retention'
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
@@ -55,6 +56,7 @@ const platform = process.env.TURSO_DATABASE_URL
})
: createNodePlatform()
const deps = createDeps(platform)
const app = await createBootstrap(platform)
const server = new Hono()
@@ -81,21 +83,21 @@ console.log('licensing.refresh.scheduler.started interval=6h')
setInterval(() => {
// runLicensingRefresh handles all errors internally and never rejects.
void (async () => {
const instanceUrl = await getSitePublicOrigin(platform.db)
const instanceUrl = await getSitePublicOrigin(deps)
const instance = instanceUrl
? await buildCloudInstanceInfo(platform.db, {
? await buildCloudInstanceInfo(deps, {
url: instanceUrl,
runtime: runtimeInfo(platform),
})
: undefined
await runLicensingRefresh(platform.db, cloudBaseUrl, instance)
await runLicensingRefresh(deps, cloudBaseUrl, instance)
})()
}, REFRESH_INTERVAL_MS)
console.log('traffic.sync.scheduler.started interval=10m')
setInterval(() => {
void syncPendingCloudTrafficReports({ db: platform.db, cloudBaseUrl })
void syncPendingRemoteDownloadUsageReports({ db: platform.db, cloudBaseUrl })
void syncPendingCloudTrafficReports(deps, { cloudBaseUrl })
void syncPendingRemoteDownloadUsageReports(deps, { cloudBaseUrl })
}, TRAFFIC_SYNC_INTERVAL_MS)
function reportNodeInstanceTelemetry(): void {
@@ -103,8 +105,7 @@ function reportNodeInstanceTelemetry(): void {
void (async () => {
try {
await reportInstanceTelemetry({
db: platform.db,
await reportInstanceTelemetry(deps, {
config: {
allowIp: envAllowsIp(process.env.ZPAN_TELEMETRY_ALLOW_IP),
},
@@ -132,19 +133,16 @@ setInterval(reportNodeInstanceTelemetry, INSTANCE_TELEMETRY_INTERVAL_MS)
console.log('quota.reset.scheduler.started interval=24h')
// Run once at boot to catch a month boundary crossed while the server was down.
void resetExpiredTrafficQuotas(platform.db)
void createQuotaRepo(platform.db).resetExpiredTrafficQuotas()
setInterval(() => {
void resetExpiredTrafficQuotas(platform.db)
void createQuotaRepo(platform.db).resetExpiredTrafficQuotas()
}, QUOTA_RESET_INTERVAL_MS)
console.log('trash.purge.scheduler.started interval=24h')
function purgeExpiredTrashJob(): void {
void (async () => {
try {
const purged = await purgeExpiredTrash(
platform.db,
resolveTrashRetentionDays(process.env.ZPAN_TRASH_RETENTION_DAYS),
)
const purged = await purgeExpiredTrash(deps, resolveTrashRetentionDays(process.env.ZPAN_TRASH_RETENTION_DAYS))
if (purged > 0) console.log(`trash.purge.done count=${purged}`)
} catch (err) {
console.error(`trash.purge.error code=${err instanceof Error ? err.message : String(err)}`)
@@ -28,7 +28,7 @@ describe('Admin Announcements API', () => {
expect(res.status).toBe(401)
})
it('returns 403 for non-admin users', async () => {
it('returns 403 for non-admin users [spec: announcements/admin-only]', async () => {
const { app } = await createTestApp()
await adminHeaders(app)
const headers = await authedHeaders(app, 'user@example.com')
@@ -47,7 +47,7 @@ describe('Admin Announcements API', () => {
expect(body.feature).toBe('site_announcements')
})
it('creates, lists, updates, and deletes an announcement', async () => {
it('creates, lists, updates, and deletes an announcement [spec: announcements/crud]', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await seedBusinessLicense(db)
@@ -100,7 +100,7 @@ describe('User Announcements API', () => {
expect(body.feature).toBe('site_announcements')
})
it('returns active announcements', async () => {
it('returns active announcements [spec: announcements/user-active]', async () => {
const ctx = await createTestApp()
const { app } = ctx
const created = await createPublishedAnnouncement(ctx)
@@ -113,7 +113,7 @@ describe('User Announcements API', () => {
expect(active.items[0].id).toBe(created.id)
})
it('keeps archived announcements in history but not active list', async () => {
it('keeps archived announcements in history but not active list [spec: announcements/archived-history]', async () => {
const { app, db } = await createTestApp()
const admin = await adminHeaders(app)
await seedBusinessLicense(db)
@@ -142,7 +142,7 @@ describe('User Announcements API', () => {
expect(history.items[0]).toMatchObject({ id: created.id, status: 'archived' })
})
it('does not include draft announcements in history', async () => {
it('does not include draft announcements in history [spec: announcements/no-drafts]', async () => {
const { app, db } = await createTestApp()
const admin = await adminHeaders(app)
await seedBusinessLicense(db)
@@ -159,7 +159,7 @@ describe('User Announcements API', () => {
expect(body.total).toBe(0)
})
it('rejects invalid pagination query values', async () => {
it('rejects invalid pagination query values [spec: announcements/pagination-validation]', async () => {
const ctx = await createTestApp()
const { app } = ctx
await createPublishedAnnouncement(ctx)
@@ -8,14 +8,6 @@ import {
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { requireFeature } from '../middleware/require-feature'
import {
createAnnouncement,
deleteAnnouncement,
getAnnouncement,
listAdminAnnouncements,
listUserAnnouncements,
updateAnnouncement,
} from '../services/announcement'
function pagination(query: { page?: string; pageSize?: string }) {
return {
@@ -28,9 +20,8 @@ export const announcements = new Hono<Env>()
.use(requireAuth)
.use(requireFeature('site_announcements'))
.get('/', zValidator('query', listAnnouncementsQuerySchema), async (c) => {
const db = c.get('platform').db
const query = c.req.valid('query')
const result = await listUserAnnouncements(db, {
const result = await c.get('deps').announcements.listUser({
activeOnly: query.scope === 'active',
...pagination(query),
})
@@ -41,35 +32,30 @@ export const adminAnnouncements = new Hono<Env>()
.use(requireAdmin)
.use(requireFeature('site_announcements'))
.get('/', zValidator('query', listAdminAnnouncementsQuerySchema), async (c) => {
const db = c.get('platform').db
const query = c.req.valid('query')
const result = await listAdminAnnouncements(db, { status: query.status, ...pagination(query) })
const result = await c.get('deps').announcements.listAdmin({ status: query.status, ...pagination(query) })
return c.json(result)
})
.post('/', zValidator('json', announcementInputSchema), async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const announcement = await createAnnouncement(db, c.req.valid('json'), userId)
const announcement = await c.get('deps').announcements.create(c.req.valid('json'), userId)
return c.json(announcement, 201)
})
.get('/:id', async (c) => {
const db = c.get('platform').db
const id = c.req.param('id')
const announcement = await getAnnouncement(db, id)
const announcement = await c.get('deps').announcements.get(id)
if (!announcement) return c.json({ error: 'Announcement not found' }, 404)
return c.json(announcement)
})
.put('/:id', zValidator('json', announcementInputSchema), async (c) => {
const db = c.get('platform').db
const id = c.req.param('id')
const announcement = await updateAnnouncement(db, id, c.req.valid('json'))
const announcement = await c.get('deps').announcements.update(id, c.req.valid('json'))
if (!announcement) return c.json({ error: 'Announcement not found' }, 404)
return c.json(announcement)
})
.delete('/:id', async (c) => {
const db = c.get('platform').db
const id = c.req.param('id')
const deleted = await deleteAnnouncement(db, id)
const deleted = await c.get('deps').announcements.delete(id)
if (!deleted) return c.json({ error: 'Announcement not found' }, 404)
return c.json({ id, deleted: true })
})
@@ -6,9 +6,9 @@
*/
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { S3Service } from '../adapters/gateways/s3.js'
import { createShareRepo } from '../adapters/repos/share'
import { activityEvents } from '../db/schema.js'
import { S3Service } from '../services/s3.js'
import { createShare } from '../services/share.js'
import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -682,7 +682,7 @@ describe('Share download audit events', () => {
const orgId = await getPersonalOrgId(db)
const creatorId = (await db.all<{ id: string }>(sql`SELECT id FROM user LIMIT 1`))[0].id
await insertFile(db, orgId, { id: 'dl-audit-1', name: 'report.pdf' })
const share = await createShare(db, { matterId: 'dl-audit-1', orgId, creatorId, kind: 'landing' })
const share = await createShareRepo(db).create({ matterId: 'dl-audit-1', orgId, creatorId, kind: 'landing' })
// Fetch rootRef from share metadata
const metaRes = await app.request(`/api/shares/${share.token}`)
@@ -710,7 +710,7 @@ describe('Share download audit events', () => {
const orgId = await getPersonalOrgId(db)
const creatorId = (await db.all<{ id: string }>(sql`SELECT id FROM user LIMIT 1`))[0].id
await insertFile(db, orgId, { id: 'dl-audit-2', name: 'anon.pdf' })
const share = await createShare(db, { matterId: 'dl-audit-2', orgId, creatorId, kind: 'landing' })
const share = await createShareRepo(db).create({ matterId: 'dl-audit-2', orgId, creatorId, kind: 'landing' })
const metaRes = await app.request(`/api/shares/${share.token}`)
const meta = (await metaRes.json()) as { rootRef: string }
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'
import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
describe('GET /api/admin/audit — auth guards', () => {
it('returns 401 without auth', async () => {
it('returns 401 without auth [spec: audit/auth-required]', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/admin/audit')
expect(res.status).toBe(401)
})
it('returns 403 for authenticated non-admin', async () => {
it('returns 403 for authenticated non-admin [spec: audit/admin-only]', async () => {
const { app } = await createTestApp()
await adminHeaders(app)
const headers = await authedHeaders(app, 'user@example.com')
@@ -16,7 +16,7 @@ describe('GET /api/admin/audit — auth guards', () => {
expect(res.status).toBe(403)
})
it('returns 402 when admin lacks audit_log feature', async () => {
it('returns 402 when admin lacks audit_log feature [spec: audit/feature-gated]', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
// No Pro license seeded — feature gate should block
@@ -28,7 +28,7 @@ describe('GET /api/admin/audit — auth guards', () => {
})
describe('GET /api/admin/audit — licensed admin', () => {
it('returns empty list when no events exist', async () => {
it('returns empty list when no events exist [spec: audit/empty]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -42,7 +42,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
expect(body.pageSize).toBe(20)
})
it('lists events across multiple orgs, newest first', async () => {
it('lists events across multiple orgs, newest first [spec: audit/list-newest-first]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -85,7 +85,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
expect(body.items[1].id).toBe('evt-a')
})
it('filters by orgId', async () => {
it('filters by orgId [spec: audit/filter-org]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -123,7 +123,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
expect(body.items[0].orgId).toBe('org-x')
})
it('filters by userId', async () => {
it('filters by userId [spec: audit/filter-user]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -160,7 +160,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
expect(body.items[0].userId).toBe('alice')
})
it('filters by action', async () => {
it('filters by action [spec: audit/filter-action]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -197,7 +197,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
expect(body.items[0].action).toBe('upload')
})
it('filters by targetType', async () => {
it('filters by targetType [spec: audit/filter-target-type]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -234,7 +234,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
expect(body.items[0].targetType).toBe('folder')
})
it('respects pagination params and returns correct page/pageSize', async () => {
it('respects pagination params and returns correct page/pageSize [spec: audit/pagination]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -268,7 +268,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
expect(body.items).toHaveLength(1)
})
it('response items include actor display info and orgName', async () => {
it('response items include actor display info and orgName [spec: audit/actor-info]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await adminHeaders(app)
@@ -4,18 +4,16 @@ import { listAdminAuditQuerySchema } from '../../shared/schemas'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { requireFeature } from '../middleware/require-feature'
import { listAdminAuditEvents } from '../services/activity'
export const adminAudit = new Hono<Env>()
.use(requireAdmin)
.use(requireFeature('audit_log'))
.get('/', zValidator('query', listAdminAuditQuerySchema), async (c) => {
const db = c.get('platform').db
const query = c.req.valid('query')
const page = Math.max(1, Number(query.page ?? '1'))
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? '20')))
const result = await listAdminAuditEvents(db, {
const result = await c.get('deps').activity.listAdminAudit({
page,
pageSize,
orgId: query.orgId,
@@ -39,7 +39,7 @@ describe('Auth Providers — public list', () => {
expect(body.items).toEqual([])
})
it('returns only enabled providers', async () => {
it('returns only enabled providers [spec: auth-providers/public-enabled-only]', async () => {
const { app, db } = await createTestApp()
const admin = await adminHeaders(app)
await seedProLicense(db) // 2nd provider requires social_login_unlimited
@@ -54,7 +54,7 @@ describe('Auth Providers — public list', () => {
expect(body.items[0].providerId).toBe('github')
})
it('does not include clientSecret in public response', async () => {
it('does not include clientSecret in public response [spec: auth-providers/public-no-secret]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -65,7 +65,7 @@ describe('Auth Providers — public list', () => {
expect(body.items[0]).not.toHaveProperty('clientSecret')
})
it('returns display name and icon from provider metadata', async () => {
it('returns display name and icon from provider metadata [spec: auth-providers/metadata]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -77,7 +77,7 @@ describe('Auth Providers — public list', () => {
expect(body.items[0].icon).toBe('github')
})
it('uses providerId as fallback name and icon for unknown OIDC provider', async () => {
it('uses providerId as fallback name and icon for unknown OIDC provider [spec: auth-providers/oidc-fallback]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -110,7 +110,7 @@ describe('Auth Providers — admin list', () => {
expect(res.status).toBe(401)
})
it('returns 403 for non-admin user', async () => {
it('returns 403 for non-admin user [spec: auth-providers/admin-only]', async () => {
const { app } = await createTestApp()
// First sign-up makes admin; second is regular user
await adminHeaders(app)
@@ -134,7 +134,7 @@ describe('Auth Providers — admin list', () => {
expect(body.items).toEqual([])
})
it('returns all configs including disabled providers', async () => {
it('returns all configs including disabled providers [spec: auth-providers/admin-list-all]', async () => {
const { app, db } = await createTestApp()
const admin = await adminHeaders(app)
await seedProLicense(db) // 2nd provider requires social_login_unlimited
@@ -147,7 +147,7 @@ describe('Auth Providers — admin list', () => {
expect(body.items).toHaveLength(2)
})
it('masks clientSecret leaving only last 4 chars visible', async () => {
it('masks clientSecret leaving only last 4 chars visible [spec: auth-providers/mask-secret]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -160,7 +160,7 @@ describe('Auth Providers — admin list', () => {
expect(secret).not.toBe(githubConfig.clientSecret)
})
it('masks short secret entirely with four asterisks', async () => {
it('masks short secret entirely with four asterisks [spec: auth-providers/mask-short-secret]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -183,7 +183,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect(res.status).toBe(401)
})
it('admin can create a builtin provider', async () => {
it('admin can create a builtin provider [spec: auth-providers/create-builtin]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -196,7 +196,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect(body.enabled).toBe(true)
})
it('blocks the second provider on the free plan with 402', async () => {
it('blocks the second provider on the free plan with 402 [spec: auth-providers/free-limit]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -210,7 +210,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect(body.limit).toBe(1)
})
it('allows additional providers with the social_login_unlimited entitlement', async () => {
it('allows additional providers with the social_login_unlimited entitlement [spec: auth-providers/unlimited-entitlement]', async () => {
const { app, db } = await createTestApp()
const admin = await adminHeaders(app)
await seedProLicense(db)
@@ -219,7 +219,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect((await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id' })).status).toBe(200)
})
it('updating the only provider is not blocked by the free limit', async () => {
it('updating the only provider is not blocked by the free limit [spec: auth-providers/update-not-limited]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -238,7 +238,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect((body.clientSecret as string).endsWith('alue')).toBe(true)
})
it('updates an existing provider on second PUT', async () => {
it('updates an existing provider on second PUT [spec: auth-providers/update]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -255,7 +255,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect(listBody.items).toHaveLength(1)
})
it('admin can create an OIDC provider with discoveryUrl', async () => {
it('admin can create an OIDC provider with discoveryUrl [spec: auth-providers/create-oidc]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -268,7 +268,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect(body.scopes).toEqual(oidcConfig.scopes)
})
it('returns 400 for unknown builtin provider ID', async () => {
it('returns 400 for unknown builtin provider ID [spec: auth-providers/unknown-builtin]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -278,7 +278,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect(body.error).toMatch(/Unknown builtin provider/)
})
it('returns 400 for OIDC provider missing discoveryUrl', async () => {
it('returns 400 for OIDC provider missing discoveryUrl [spec: auth-providers/oidc-missing-discovery]', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
@@ -1,5 +1,4 @@
import { zValidator } from '@hono/zod-validator'
import { eq, like } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { FREE_SOCIAL_LOGIN_LIMIT } from '../../shared/constants'
@@ -11,10 +10,10 @@ import {
OAuthProviderMeta,
parseProviderConfig,
} from '../../shared/oauth-providers'
import { systemOptions } from '../db/schema'
import { hasFeature, loadBindingState } from '../licensing/has-feature'
import { hasFeature } from '../domain/licensing'
import { requireAdmin } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { loadBindingState } from '../usecases/licensing'
function optionKey(providerId: string): string {
return `${OAUTH_PROVIDER_KEY_PREFIX}${providerId}`
@@ -36,8 +35,7 @@ const upsertSchema = z.object({
// Public: enabled providers only, no secrets (for login page buttons)
export const publicAuthProviders = new Hono<Env>().get('/', async (c) => {
const db = c.get('platform').db
const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
const rows = await c.get('deps').systemOptions.listByKeyLike(OAUTH_PROVIDER_KEY_PATTERN)
const items = rows
.map((r) => {
const config = parseProviderConfig(r.value)
@@ -58,8 +56,7 @@ export const publicAuthProviders = new Hono<Env>().get('/', async (c) => {
export const adminAuthProviders = new Hono<Env>()
.use(requireAdmin)
.get('/', async (c) => {
const db = c.get('platform').db
const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
const rows = await c.get('deps').systemOptions.listByKeyLike(OAUTH_PROVIDER_KEY_PATTERN)
const items = rows
.map((r) => {
const config = parseProviderConfig(r.value)
@@ -70,7 +67,6 @@ export const adminAuthProviders = new Hono<Env>()
return c.json({ items })
})
.put('/:providerId', zValidator('json', upsertSchema), async (c) => {
const db = c.get('platform').db
const providerId = c.req.param('providerId')
const body = c.req.valid('json')
@@ -88,16 +84,13 @@ export const adminAuthProviders = new Hono<Env>()
const key = optionKey(providerId)
const value = JSON.stringify(config)
const existing = await db.select({ key: systemOptions.key }).from(systemOptions).where(eq(systemOptions.key, key))
if (existing.length > 0) {
await db.update(systemOptions).set({ value, public: false }).where(eq(systemOptions.key, key))
const existing = await c.get('deps').systemOptions.get(key)
if (existing) {
await c.get('deps').systemOptions.set(key, value, false)
} else {
const [configured, state] = await Promise.all([
db
.select({ key: systemOptions.key })
.from(systemOptions)
.where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN)),
loadBindingState(db),
c.get('deps').systemOptions.listByKeyLike(OAUTH_PROVIDER_KEY_PATTERN),
loadBindingState(c.get('deps')),
])
if (!hasFeature('social_login_unlimited', state) && configured.length >= FREE_SOCIAL_LOGIN_LIMIT) {
return c.json(
@@ -111,17 +104,16 @@ export const adminAuthProviders = new Hono<Env>()
402,
)
}
await db.insert(systemOptions).values({ key, value, public: false })
await c.get('deps').systemOptions.set(key, value, false)
}
return c.json({ ...config, clientSecret: maskSecret(config.clientSecret) })
})
.delete('/:providerId', async (c) => {
const db = c.get('platform').db
const providerId = c.req.param('providerId')
if (!isValidProviderId(providerId)) {
return c.json({ error: 'Provider ID must contain only lowercase letters, numbers, and hyphens' }, 400)
}
await db.delete(systemOptions).where(eq(systemOptions.key, optionKey(providerId)))
await c.get('deps').systemOptions.delete(optionKey(providerId))
return c.json({ providerId, deleted: true })
})
@@ -23,7 +23,7 @@ describe('migration 0004_username_plugin.sql', () => {
})
describe('username plugin — sign-up with username', () => {
it('sign-up with username stores the username on the user record', async () => {
it('sign-up with username stores the username on the user record [spec: auth-username/signup-with-username]', async () => {
const { app, db } = await createTestApp()
await app.request('/api/auth/sign-up/email', {
method: 'POST',
@@ -39,7 +39,7 @@ describe('username plugin — sign-up with username', () => {
expect(users[0].username).toBe('alice42')
})
it('sign-up without username generates a username from the email prefix', async () => {
it('sign-up without username generates a username from the email prefix [spec: auth-username/signup-generates-username]', async () => {
const { app, db } = await createTestApp()
await app.request('/api/auth/sign-up/email', {
method: 'POST',
@@ -50,7 +50,7 @@ describe('username plugin — sign-up with username', () => {
expect(users[0].username).toBe('bob')
})
it('sign-up with duplicate username returns a non-200 response', async () => {
it('sign-up with duplicate username returns a non-200 response [spec: auth-username/duplicate-rejected]', async () => {
const { app } = await createTestApp()
await app.request('/api/auth/sign-up/email', {
method: 'POST',
@@ -75,7 +75,7 @@ describe('username plugin — sign-up with username', () => {
expect(res.status).not.toBe(200)
})
it('two users with different usernames both register successfully', async () => {
it('two users with different usernames both register successfully [spec: auth-username/distinct-usernames]', async () => {
const { app, db } = await createTestApp()
const res1 = await app.request('/api/auth/sign-up/email', {
method: 'POST',

Some files were not shown because too many files have changed in this diff Show More