diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs new file mode 100644 index 00000000..24884a83 --- /dev/null +++ b/.dependency-cruiser.cjs @@ -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, + }, +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b198ad3..fbe40ca5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/clean-arch-migration.md b/docs/clean-arch-migration.md new file mode 100644 index 00000000..e66e0135 --- /dev/null +++ b/docs/clean-arch-migration.md @@ -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/.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/.ts` — plain DTOs + repo/gateway interface. No drizzle, + no zod runtime (type-only shared imports OK). Add `export * from './ports/'` + to `usecases/ports.ts`. +2. Adapter: `adapters/repos/.ts` — `createRepo(db): 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')..(...)` + - not-yet-migrated `services/` / `auth.ts` → `createRepo(db).(...)` + (transitional; removed when that service itself migrates) +5. Delete the old `services/.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.` (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: ]` 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 `_`/`%`. diff --git a/package.json b/package.json index 8ec0caff..c4457b7b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5b5e28f..4847ed06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/lint-spec.mjs b/scripts/lint-spec.mjs new file mode 100644 index 00000000..70574043 --- /dev/null +++ b/scripts/lint-spec.mjs @@ -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 `@/`. Its home +// test carries `[spec: /]` 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) diff --git a/server/adapters/gateways/archive-jobs.ts b/server/adapters/gateways/archive-jobs.ts new file mode 100644 index 00000000..1a540879 --- /dev/null +++ b/server/adapters/gateways/archive-jobs.ts @@ -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 +} + +// 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) {} + + push(message: ArchiveJobMessage): void { + this.pending.push(message) + if (!this.running) setTimeout(() => void this.drain(), 0) + } + + private async drain(): Promise { + 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 { + 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(ARCHIVE_QUEUE_BINDING) + if (queue) { + await queue.send(message) + return + } + localQueue.push(message) + }, + runMessage, + } +} diff --git a/server/services/email.integration.test.ts b/server/adapters/gateways/email.integration.test.ts similarity index 73% rename from server/services/email.integration.test.ts rename to server/adapters/gateways/email.integration.test.ts index 938eb288..c1c4e4d4 100644 --- a/server/services/email.integration.test.ts +++ b/server/adapters/gateways/email.integration.test.ts @@ -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>['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: (_key: string) => undefined as T | undefined, + } +} + +function platformWithBinding(db: TestDb, binding: unknown): Platform { + return { + db, + getEnv: () => undefined, + getBinding: (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: (_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: (_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: '

Hi

' }) + await gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hello', html: '

Hi

' }) 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: '

Hi

' }) + await gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hello', html: '

Hi

' }) 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: '

Hi

' })).rejects.toThrow( - 'HTTP email API error (422): Invalid recipient', - ) + await expect( + gatewayFor(db).send(bareplatform(db), { to: 'bad@example.com', subject: 'Hi', html: '

Hi

' }), + ).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: (key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined), - } satisfies Platform - await sendEmail(platform, { to: 'user@example.com', subject: 'Hello', html: '

Hi there

' }) + await gatewayFor(db).send(platformWithBinding(db, { send: sendMock }), { + to: 'user@example.com', + subject: 'Hello', + html: '

Hi there

', + }) 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: (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: '

Hi

' })).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: '

Hi

' }), + ).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: (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: '

Hi there

', diff --git a/server/adapters/gateways/email.ts b/server/adapters/gateways/email.ts new file mode 100644 index 00000000..2632d388 --- /dev/null +++ b/server/adapters/gateways/email.ts @@ -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(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 { + // 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 { + 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 { + 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> { + const rows = await systemOptions.listByKeyLike('email_%') + return new Map(rows.map((r) => [r.key, r.value])) + } + + async function getConfig(platform: Platform): Promise { + 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 { + const opts = await loadOptions() + return opts.get('email_enabled') === 'true' + } + + async function isConfigured(platform: Platform): Promise { + 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 { + 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 { + 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) + }, + } +} diff --git a/server/adapters/gateways/image-upload.test.ts b/server/adapters/gateways/image-upload.test.ts new file mode 100644 index 00000000..28706901 --- /dev/null +++ b/server/adapters/gateways/image-upload.test.ts @@ -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; publicUrl?: string } = {}): Platform { + return { + db: {} as Any, + getEnv: (key) => (key === 'PUBLIC_IMAGES_URL' ? opts.publicUrl : undefined), + getBinding: (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 + getPublicUrl: ReturnType + deleteObject: ReturnType + } +} + +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() + }) +}) diff --git a/server/adapters/gateways/image-upload.ts b/server/adapters/gateways/image-upload.ts new file mode 100644 index 00000000..020f5dc8 --- /dev/null +++ b/server/adapters/gateways/image-upload.ts @@ -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 + delete(key: string): Promise +} + +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 { + const r2 = platform.getBinding('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 { + 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 { + 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) + } + }, + } +} diff --git a/server/services/licensing-cloud.test.ts b/server/adapters/gateways/licensing-cloud.test.ts similarity index 98% rename from server/services/licensing-cloud.test.ts rename to server/adapters/gateways/licensing-cloud.test.ts index 3f225977..788edd24 100644 --- a/server/services/licensing-cloud.test.ts +++ b/server/adapters/gateways/licensing-cloud.test.ts @@ -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, diff --git a/server/services/licensing-cloud.ts b/server/adapters/gateways/licensing-cloud.ts similarity index 74% rename from server/services/licensing-cloud.ts rename to server/adapters/gateways/licensing-cloud.ts index fe07bd61..4018687d 100644 --- a/server/services/licensing-cloud.ts +++ b/server/adapters/gateways/licensing-cloud.ts @@ -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, + } +} diff --git a/server/services/s3.test.ts b/server/adapters/gateways/s3.test.ts similarity index 99% rename from server/services/s3.test.ts rename to server/adapters/gateways/s3.test.ts index 73b09ff5..536399f3 100644 --- a/server/services/s3.test.ts +++ b/server/adapters/gateways/s3.test.ts @@ -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() diff --git a/server/services/s3.ts b/server/adapters/gateways/s3.ts similarity index 97% rename from server/services/s3.ts rename to server/adapters/gateways/s3.ts index 019c4c9d..02b2e80a 100644 --- a/server/services/s3.ts +++ b/server/adapters/gateways/s3.ts @@ -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(/</g, '<') .replace(/&/g, '&') } + +export type { S3StorageCredentials } from '../../usecases/ports' diff --git a/server/services/zip-extract.ts b/server/adapters/gateways/zip.ts similarity index 79% rename from server/services/zip-extract.ts rename to server/adapters/gateways/zip.ts index 82d14cd3..81ba3008 100644 --- a/server/services/zip-extract.ts +++ b/server/adapters/gateways/zip.ts @@ -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 { + return new ReadableStream({ + 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 - size: Promise +async function streamZipEntries( + zip: Zip, + sources: ZipSourceStream[], + directories: CompressionSourceDirectory[], + waitForWrites: () => Promise, +): Promise { + 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, + entry: ZipDeflate, + waitForWrites: () => Promise, +): Promise { + 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, ): Promise { @@ -103,7 +145,7 @@ export async function validateZipDirectory( } } -export async function streamValidatedZip( +async function streamValidatedZip( data: ReadableStream, onFile: (file: StreamingZipFile) => Promise, ): Promise { @@ -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, + } +} diff --git a/server/services/cf-custom-hostnames.test.ts b/server/adapters/providers/cf-custom-hostnames.test.ts similarity index 98% rename from server/services/cf-custom-hostnames.test.ts rename to server/adapters/providers/cf-custom-hostnames.test.ts index faea9311..e320cb27 100644 --- a/server/services/cf-custom-hostnames.test.ts +++ b/server/adapters/providers/cf-custom-hostnames.test.ts @@ -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', diff --git a/server/services/cf-custom-hostnames.ts b/server/adapters/providers/cf-custom-hostnames.ts similarity index 93% rename from server/services/cf-custom-hostnames.ts rename to server/adapters/providers/cf-custom-hostnames.ts index af5b3130..00c41414 100644 --- a/server/services/cf-custom-hostnames.ts +++ b/server/adapters/providers/cf-custom-hostnames.ts @@ -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') diff --git a/server/services/changelog.test.ts b/server/adapters/providers/changelog.test.ts similarity index 100% rename from server/services/changelog.test.ts rename to server/adapters/providers/changelog.test.ts diff --git a/server/services/changelog.ts b/server/adapters/providers/changelog.ts similarity index 83% rename from server/services/changelog.ts rename to server/adapters/providers/changelog.ts index 9ad60a96..97031869 100644 --- a/server/services/changelog.ts +++ b/server/adapters/providers/changelog.ts @@ -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 { // 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 } +} diff --git a/server/adapters/repos/activity.ts b/server/adapters/repos/activity.ts new file mode 100644 index 00000000..1124ff6d --- /dev/null +++ b/server/adapters/repos/activity.ts @@ -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 + + 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 } + }, + } +} diff --git a/server/adapters/repos/announcement.ts b/server/adapters/repos/announcement.ts new file mode 100644 index 00000000..bda69ca2 --- /dev/null +++ b/server/adapters/repos/announcement.ts @@ -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 { + 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 } + }, + } +} diff --git a/server/services/api-keys-rate-limit.integration.test.ts b/server/adapters/repos/api-keys-rate-limit.integration.test.ts similarity index 90% rename from server/services/api-keys-rate-limit.integration.test.ts rename to server/adapters/repos/api-keys-rate-limit.integration.test.ts index 96403626..fdb53c3d 100644 --- a/server/services/api-keys-rate-limit.integration.test.ts +++ b/server/adapters/repos/api-keys-rate-limit.integration.test.ts @@ -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> @@ -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 }) }) diff --git a/server/adapters/repos/api-keys.ts b/server/adapters/repos/api-keys.ts new file mode 100644 index 00000000..1ea82c0f --- /dev/null +++ b/server/adapters/repos/api-keys.ts @@ -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): Promise { + 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 { + 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 +} diff --git a/server/adapters/repos/archive-target-folder.ts b/server/adapters/repos/archive-target-folder.ts new file mode 100644 index 00000000..e528896f --- /dev/null +++ b/server/adapters/repos/archive-target-folder.ts @@ -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 { + 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), + } +} diff --git a/server/services/background-jobs.test.ts b/server/adapters/repos/background-job.test.ts similarity index 60% rename from server/services/background-jobs.test.ts rename to server/adapters/repos/background-job.test.ts index 61833294..f22b9f73 100644 --- a/server/services/background-jobs.test.ts +++ b/server/adapters/repos/background-job.test.ts @@ -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', }) }) diff --git a/server/adapters/repos/background-job.ts b/server/adapters/repos/background-job.ts new file mode 100644 index 00000000..b538f7ec --- /dev/null +++ b/server/adapters/repos/background-job.ts @@ -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 { + 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 = { + 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 +} diff --git a/server/services/cloud-store.test.ts b/server/adapters/repos/cloud-store.test.ts similarity index 97% rename from server/services/cloud-store.test.ts rename to server/adapters/repos/cloud-store.test.ts index 7187d3db..003b1ba6 100644 --- a/server/services/cloud-store.test.ts +++ b/server/adapters/repos/cloud-store.test.ts @@ -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['processCloudOrderQuotaChange']> +) { + return createCloudStoreRepo(db).processCloudOrderQuotaChange(...args) +} function createAsyncDb( quotaRows: Array<{ id: string }> = [{ id: 'quota-1' }], diff --git a/server/services/cloud-store.ts b/server/adapters/repos/cloud-store.ts similarity index 91% rename from server/services/cloud-store.ts rename to server/adapters/repos/cloud-store.ts index 6b9a8a39..78cd0ade 100644 --- a/server/services/cloud-store.ts +++ b/server/adapters/repos/cloud-store.ts @@ -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 { +async function getAccessibleTargets(db: Database, userId: string): Promise { 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 { + 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 { +async function getCustomerLabel(db: Database, userId: string, orgId: string): Promise { 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), + } +} diff --git a/server/adapters/repos/cloud-traffic-report.ts b/server/adapters/repos/cloud-traffic-report.ts new file mode 100644 index 00000000..1f05b0eb --- /dev/null +++ b/server/adapters/repos/cloud-traffic-report.ts @@ -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) + }, + } +} diff --git a/server/adapters/repos/download-task.ts b/server/adapters/repos/download-task.ts new file mode 100644 index 00000000..6b217661 --- /dev/null +++ b/server/adapters/repos/download-task.ts @@ -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, 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` + 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`coalesce(json_extract(${downloadTasks.runtime}, '$.etaSeconds'), 9223372036854775807)`) + } + return direction(downloadTasks.createdAt) +} + +export function createDownloadTaskRepo(db: Database): DownloadTaskRepo { + async function findRow(id: string): Promise { + 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))) + }, + } +} diff --git a/server/adapters/repos/download-tokens.ts b/server/adapters/repos/download-tokens.ts new file mode 100644 index 00000000..fa08a7af --- /dev/null +++ b/server/adapters/repos/download-tokens.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/server/adapters/repos/downloader.ts b/server/adapters/repos/downloader.ts new file mode 100644 index 00000000..b691f424 --- /dev/null +++ b/server/adapters/repos/downloader.ts @@ -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 { + 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)) + }, + } +} diff --git a/server/adapters/repos/image-hosting-config.ts b/server/adapters/repos/image-hosting-config.ts new file mode 100644 index 00000000..a6d9af83 --- /dev/null +++ b/server/adapters/repos/image-hosting-config.ts @@ -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)) + }, + } +} diff --git a/server/adapters/repos/image-hosting.ts b/server/adapters/repos/image-hosting.ts new file mode 100644 index 00000000..5668bff1 --- /dev/null +++ b/server/adapters/repos/image-hosting.ts @@ -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 { + 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 { + 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 { + 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))) + }, + } +} diff --git a/server/adapters/repos/instance.ts b/server/adapters/repos/instance.ts new file mode 100644 index 00000000..41832f7d --- /dev/null +++ b/server/adapters/repos/instance.ts @@ -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' + }, + } +} diff --git a/server/services/invite.integration.test.ts b/server/adapters/repos/invite.integration.test.ts similarity index 61% rename from server/services/invite.integration.test.ts rename to server/adapters/repos/invite.integration.test.ts index ff65a6fb..06c43fb9 100644 --- a/server/services/invite.integration.test.ts +++ b/server/adapters/repos/invite.integration.test.ts @@ -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') }) }) diff --git a/server/adapters/repos/invite.ts b/server/adapters/repos/invite.ts new file mode 100644 index 00000000..6739a5e9 --- /dev/null +++ b/server/adapters/repos/invite.ts @@ -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' + }, + } +} diff --git a/server/licensing/license-state.ts b/server/adapters/repos/license-binding.ts similarity index 65% rename from server/licensing/license-state.ts rename to server/adapters/repos/license-binding.ts index 5a2fa9ed..0621ff15 100644 --- a/server/licensing/license-state.ts +++ b/server/adapters/repos/license-binding.ts @@ -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 { +async function loadLicenseState(db: Database): Promise { const row = await loadActiveLicenseBinding(db) return row ?? emptyLicenseState() } -export async function loadActiveLicenseBinding(db: Database): Promise { +async function loadActiveLicenseBinding(db: Database): Promise { 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 { +async function createLicenseBinding(db: Database, input: CreateLicenseBindingInput): Promise { 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 { +async function updateLicenseBindingAfterRefresh(db: Database, input: UpdateLicenseBindingInput): Promise { 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 { +async function setLicenseRefreshError(db: Database, id: string, error: string): Promise { 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 { +async function clearLicenseBinding(db: Database, status: LicenseBindingStatus = 'disconnected'): Promise { 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), + } +} diff --git a/server/services/matter-conflict.integration.test.ts b/server/adapters/repos/matter-conflict.integration.test.ts similarity index 91% rename from server/services/matter-conflict.integration.test.ts rename to server/adapters/repos/matter-conflict.integration.test.ts index 9b783e87..61df19e6 100644 --- a/server/services/matter-conflict.integration.test.ts +++ b/server/adapters/repos/matter-conflict.integration.test.ts @@ -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>['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 { + 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) { diff --git a/server/services/matter-name-conflict.test.ts b/server/adapters/repos/matter-name-conflict.integration.test.ts similarity index 92% rename from server/services/matter-name-conflict.test.ts rename to server/adapters/repos/matter-name-conflict.integration.test.ts index dca48345..e0ccadc0 100644 --- a/server/services/matter-name-conflict.test.ts +++ b/server/adapters/repos/matter-name-conflict.integration.test.ts @@ -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>['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` diff --git a/server/services/matter.integration.test.ts b/server/adapters/repos/matter.integration.test.ts similarity index 84% rename from server/services/matter.integration.test.ts rename to server/adapters/repos/matter.integration.test.ts index e4bd31cc..6c6b3fd2 100644 --- a/server/services/matter.integration.test.ts +++ b/server/adapters/repos/matter.integration.test.ts @@ -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>['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>['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) diff --git a/server/adapters/repos/matter.ts b/server/adapters/repos/matter.ts new file mode 100644 index 00000000..677de12f --- /dev/null +++ b/server/adapters/repos/matter.ts @@ -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 { + 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 { + return db + .select() + .from(matters) + .where(and(eq(matters.orgId, orgId), descendantParentCondition(folderPath))) + } + + function getDirectChildren(orgId: string, folderPath: string): Promise { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + function collectForPurge(orgId: string, idOrMatter: Matter): Promise + async function collectForPurge(orgId: string, idOrMatter: string | Matter): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + for (const id of ids) { + await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId))) + } + }, + + async listActiveDescendants(orgId, parentPath): Promise { + // 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 { + 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 { + 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 { + await db + .update(matters) + .set({ updatedAt: new Date() }) + .where(and(eq(matters.id, id), eq(matters.orgId, orgId))) + }, + + async applyUpload(orgId, id, fields): Promise { + 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 { + 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 { + 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 { + 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 +} diff --git a/server/adapters/repos/member-count.ts b/server/adapters/repos/member-count.ts new file mode 100644 index 00000000..abdcf213 --- /dev/null +++ b/server/adapters/repos/member-count.ts @@ -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 + }, + } +} diff --git a/server/services/notification.integration.test.ts b/server/adapters/repos/notification.integration.test.ts similarity index 53% rename from server/services/notification.integration.test.ts rename to server/adapters/repos/notification.integration.test.ts index 74b5e078..150e588f 100644 --- a/server/services/notification.integration.test.ts +++ b/server/adapters/repos/notification.integration.test.ts @@ -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>['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) }) }) diff --git a/server/adapters/repos/notification.ts b/server/adapters/repos/notification.ts new file mode 100644 index 00000000..66d9652b --- /dev/null +++ b/server/adapters/repos/notification.ts @@ -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 + }, + } +} diff --git a/server/adapters/repos/object-upload-session.ts b/server/adapters/repos/object-upload-session.ts new file mode 100644 index 00000000..9695da0e --- /dev/null +++ b/server/adapters/repos/object-upload-session.ts @@ -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)) + }, + } +} diff --git a/server/services/org.integration.test.ts b/server/adapters/repos/org.integration.test.ts similarity index 85% rename from server/services/org.integration.test.ts rename to server/adapters/repos/org.integration.test.ts index e6979905..5574ced4 100644 --- a/server/services/org.integration.test.ts +++ b/server/adapters/repos/org.integration.test.ts @@ -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>['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() }) }) diff --git a/server/services/org.test.ts b/server/adapters/repos/org.test.ts similarity index 78% rename from server/services/org.test.ts rename to server/adapters/repos/org.test.ts index 6ed4cd5b..44054a0d 100644 --- a/server/services/org.test.ts +++ b/server/adapters/repos/org.test.ts @@ -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>['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) }) }) diff --git a/server/adapters/repos/org.ts b/server/adapters/repos/org.ts new file mode 100644 index 00000000..32f161d2 --- /dev/null +++ b/server/adapters/repos/org.ts @@ -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 = { 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 { + 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 { + 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 { + 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 { + 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 { + 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 } +} diff --git a/server/adapters/repos/profile.ts b/server/adapters/repos/profile.ts new file mode 100644 index 00000000..3e3546cb --- /dev/null +++ b/server/adapters/repos/profile.ts @@ -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)) + }, + } +} diff --git a/server/services/effective-quota.test.ts b/server/adapters/repos/quota.test.ts similarity index 84% rename from server/services/effective-quota.test.ts rename to server/adapters/repos/quota.test.ts index 2814871e..e3670011 100644 --- a/server/services/effective-quota.test.ts +++ b/server/adapters/repos/quota.test.ts @@ -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) }) }) diff --git a/server/services/effective-quota.ts b/server/adapters/repos/quota.ts similarity index 86% rename from server/services/effective-quota.ts rename to server/adapters/repos/quota.ts index d3a732d2..9f744ce7 100644 --- a/server/services/effective-quota.ts +++ b/server/adapters/repos/quota.ts @@ -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 { +async function getEffectiveQuota(db: Database, orgId: string, now = new Date()): Promise { // 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 { +async function resetExpiredTrafficQuotas(db: Database, now = new Date()): Promise { 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 { +async function hasQuotaForBytes(db: Database, orgId: string, bytes: number): Promise { 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 { +async function hasTrafficQuotaForBytes(db: Database, orgId: string, bytes: number, now = new Date()): Promise { 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 { +async function refundTraffic(db: Database, orgId: string, bytes: number, now = new Date()): Promise { 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), + } +} diff --git a/server/adapters/repos/remote-download-usage.ts b/server/adapters/repos/remote-download-usage.ts new file mode 100644 index 00000000..9c142731 --- /dev/null +++ b/server/adapters/repos/remote-download-usage.ts @@ -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) + }, + } +} diff --git a/server/adapters/repos/share-notification.ts b/server/adapters/repos/share-notification.ts new file mode 100644 index 00000000..2a068201 --- /dev/null +++ b/server/adapters/repos/share-notification.ts @@ -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 + }, + } +} diff --git a/server/services/share.cf-test.ts b/server/adapters/repos/share.cf-test.ts similarity index 79% rename from server/services/share.cf-test.ts rename to server/adapters/repos/share.cf-test.ts index 84d56fb2..5a7f211b 100644 --- a/server/services/share.cf-test.ts +++ b/server/adapters/repos/share.cf-test.ts @@ -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 diff --git a/server/services/share.integration.test.ts b/server/adapters/repos/share.integration.test.ts similarity index 92% rename from server/services/share.integration.test.ts rename to server/adapters/repos/share.integration.test.ts index 931f4058..67e4fc27 100644 --- a/server/services/share.integration.test.ts +++ b/server/adapters/repos/share.integration.test.ts @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/server/adapters/repos/share.ts b/server/adapters/repos/share.ts new file mode 100644 index 00000000..317ddbe8 --- /dev/null +++ b/server/adapters/repos/share.ts @@ -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 { + 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 { + 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 { + await db + .update(shares) + .set({ views: sql`${shares.views} + 1` }) + .where(eq(shares.id, shareId)) + }, + + async hasDownloadsAvailable(shareId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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`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`(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 { + 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 { + 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 { + return quota.hasQuotaForBytes(orgId, bytes) + }, + + async getCreatorName(creatorId: string): Promise { + 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 { + 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 { + 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 { + 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 + }, + } +} diff --git a/server/services/site-invitations.ts b/server/adapters/repos/site-invitations.ts similarity index 84% rename from server/services/site-invitations.ts rename to server/adapters/repos/site-invitations.ts index e6e19c4e..b7a853f0 100644 --- a/server/services/site-invitations.ts +++ b/server/adapters/repos/site-invitations.ts @@ -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 { +async function getSiteName(db: Database): Promise { 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 { +async function createSiteInvitation(db: Database, adminUserId: string, rawEmail: string): Promise { 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 { @@ -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 { +async function getSiteInvitationByToken(db: Database, token: string): Promise { 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), + } +} diff --git a/server/adapters/repos/storage-usage.ts b/server/adapters/repos/storage-usage.ts new file mode 100644 index 00000000..a773d29f --- /dev/null +++ b/server/adapters/repos/storage-usage.ts @@ -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() + const bytesByOrg = new Map() + + 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)) + } + }, + } +} diff --git a/server/services/storage.integration.test.ts b/server/adapters/repos/storage.integration.test.ts similarity index 80% rename from server/services/storage.integration.test.ts rename to server/adapters/repos/storage.integration.test.ts index ff222792..7c7470cb 100644 --- a/server/services/storage.integration.test.ts +++ b/server/adapters/repos/storage.integration.test.ts @@ -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>['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') }) }) diff --git a/server/adapters/repos/storage.ts b/server/adapters/repos/storage.ts new file mode 100644 index 00000000..9d8ee7bb --- /dev/null +++ b/server/adapters/repos/storage.ts @@ -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 { + 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]) + }, + } +} diff --git a/server/adapters/repos/system-options.ts b/server/adapters/repos/system-options.ts new file mode 100644 index 00000000..9e69c953 --- /dev/null +++ b/server/adapters/repos/system-options.ts @@ -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)) + }, + } +} diff --git a/server/services/team-invite.integration.test.ts b/server/adapters/repos/team-invite.integration.test.ts similarity index 74% rename from server/services/team-invite.integration.test.ts rename to server/adapters/repos/team-invite.integration.test.ts index 746299d7..467ef47f 100644 --- a/server/services/team-invite.integration.test.ts +++ b/server/adapters/repos/team-invite.integration.test.ts @@ -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>['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') }) diff --git a/server/adapters/repos/team-invite.ts b/server/adapters/repos/team-invite.ts new file mode 100644 index 00000000..8dee266f --- /dev/null +++ b/server/adapters/repos/team-invite.ts @@ -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)) + }, + } +} diff --git a/server/adapters/repos/team.ts b/server/adapters/repos/team.ts new file mode 100644 index 00000000..dbe7b1fb --- /dev/null +++ b/server/adapters/repos/team.ts @@ -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(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> { + // 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() + 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)) + }, + } +} diff --git a/server/services/user.ts b/server/adapters/repos/user-admin.ts similarity index 50% rename from server/services/user.ts rename to server/adapters/repos/user-admin.ts index 13aa47cc..e8447ac5 100644 --- a/server/services/user.ts +++ b/server/adapters/repos/user-admin.ts @@ -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 { +async function getUser(db: Database, userId: string): Promise { 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 { +async function setUserStatus(db: Database, userId: string, status: 'active' | 'disabled'): Promise { 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 { +async function isBanned(db: Database, userId: string): Promise { + 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 { + 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 { 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 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 { + 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 { + 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 { + const base = existing ? (JSON.parse(existing) as Record) : {} + 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), + } +} diff --git a/server/adapters/repos/webdav-path.ts b/server/adapters/repos/webdav-path.ts new file mode 100644 index 00000000..85e78c31 --- /dev/null +++ b/server/adapters/repos/webdav-path.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/server/adapters/repos/webdav-state.cf-test.ts b/server/adapters/repos/webdav-state.cf-test.ts new file mode 100644 index 00000000..8fad3c07 --- /dev/null +++ b/server/adapters/repos/webdav-state.cf-test.ts @@ -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([]) + }) +}) diff --git a/server/adapters/repos/webdav-state.ts b/server/adapters/repos/webdav-state.ts new file mode 100644 index 00000000..696af1ab --- /dev/null +++ b/server/adapters/repos/webdav-state.ts @@ -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 { + 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}/`) +} diff --git a/server/services/zip-compress.ts b/server/adapters/repos/zip.ts similarity index 60% rename from server/services/zip-compress.ts rename to server/adapters/repos/zip.ts index d25b6690..78b3a03f 100644 --- a/server/services/zip-compress.ts +++ b/server/adapters/repos/zip.ts @@ -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> -} - -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 { 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 { - return new ReadableStream({ - 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, -): Promise { - 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, - entry: ZipDeflate, - waitForWrites: () => Promise, -): Promise { - 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() @@ -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), + } +} diff --git a/server/app.ts b/server/app.ts index 6d7e5f9b..5af7e045 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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() 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')), diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index d7401fc9..9d400f8f 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -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> @@ -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) diff --git a/server/auth.ts b/server/auth.ts index 185da78e..9ba503f9 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -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 { 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 diff --git a/server/composition.ts b/server/composition.ts new file mode 100644 index 00000000..13f4dfbb --- /dev/null +++ b/server/composition.ts @@ -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), + } +} diff --git a/server/services/db-transaction.ts b/server/db/transaction.ts similarity index 100% rename from server/services/db-transaction.ts rename to server/db/transaction.ts diff --git a/server/domain/breadcrumb.ts b/server/domain/breadcrumb.ts new file mode 100644 index 00000000..86cd0a99 --- /dev/null +++ b/server/domain/breadcrumb.ts @@ -0,0 +1,4 @@ +export function buildBreadcrumb(dir: string): string[] { + if (!dir) return [] + return dir.split('/') +} diff --git a/server/services/captcha.test.ts b/server/domain/captcha.test.ts similarity index 95% rename from server/services/captcha.test.ts rename to server/domain/captcha.test.ts index b10b2ae3..60411fca 100644 --- a/server/services/captcha.test.ts +++ b/server/domain/captcha.test.ts @@ -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() }) diff --git a/server/domain/captcha.ts b/server/domain/captcha.ts new file mode 100644 index 00000000..64d007e9 --- /dev/null +++ b/server/domain/captcha.ts @@ -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> + +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 +} diff --git a/server/domain/image-hosting.ts b/server/domain/image-hosting.ts new file mode 100644 index 00000000..282d9944 --- /dev/null +++ b/server/domain/image-hosting.ts @@ -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 +} diff --git a/server/licensing/public-keys.test.ts b/server/domain/license-keys.test.ts similarity index 98% rename from server/licensing/public-keys.test.ts rename to server/domain/license-keys.test.ts index 1f666ce6..080ecca4 100644 --- a/server/licensing/public-keys.test.ts +++ b/server/domain/license-keys.test.ts @@ -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(() => { diff --git a/server/licensing/public-keys.ts b/server/domain/license-keys.ts similarity index 100% rename from server/licensing/public-keys.ts rename to server/domain/license-keys.ts diff --git a/server/licensing/has-feature.test.ts b/server/domain/licensing.test.ts similarity index 97% rename from server/licensing/has-feature.test.ts rename to server/domain/licensing.test.ts index feb5cb8f..29f3d58b 100644 --- a/server/licensing/has-feature.test.ts +++ b/server/domain/licensing.test.ts @@ -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', () => { diff --git a/server/domain/licensing.ts b/server/domain/licensing.ts new file mode 100644 index 00000000..bb3226b3 --- /dev/null +++ b/server/domain/licensing.ts @@ -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(['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)) +} diff --git a/server/domain/matter-name-conflict.ts b/server/domain/matter-name-conflict.ts new file mode 100644 index 00000000..851bbc32 --- /dev/null +++ b/server/domain/matter-name-conflict.ts @@ -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)}` +} diff --git a/server/domain/quota.ts b/server/domain/quota.ts new file mode 100644 index 00000000..40ce64cf --- /dev/null +++ b/server/domain/quota.ts @@ -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}` +} diff --git a/server/domain/share.ts b/server/domain/share.ts new file mode 100644 index 00000000..96b3f92b --- /dev/null +++ b/server/domain/share.ts @@ -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) +} diff --git a/server/domain/site-public-origin.ts b/server/domain/site-public-origin.ts new file mode 100644 index 00000000..732709ff --- /dev/null +++ b/server/domain/site-public-origin.ts @@ -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 + } +} diff --git a/server/services/webdav-xml.ts b/server/domain/webdav-xml.ts similarity index 97% rename from server/services/webdav-xml.ts rename to server/domain/webdav-xml.ts index 46be83f0..97361a1f 100644 --- a/server/services/webdav-xml.ts +++ b/server/domain/webdav-xml.ts @@ -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): Response { return new Response(body, { status, diff --git a/server/domain/webdav.ts b/server/domain/webdav.ts new file mode 100644 index 00000000..1fa0f80f --- /dev/null +++ b/server/domain/webdav.ts @@ -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): 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()}"` +} diff --git a/server/entry-node.ts b/server/entry-node.ts index 005977e5..c33e6833 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -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)}`) diff --git a/server/routes/announcements.integration.test.ts b/server/http/announcements.integration.test.ts similarity index 92% rename from server/routes/announcements.integration.test.ts rename to server/http/announcements.integration.test.ts index 8ce7c87e..5fa8ad8f 100644 --- a/server/routes/announcements.integration.test.ts +++ b/server/http/announcements.integration.test.ts @@ -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) diff --git a/server/routes/announcements.ts b/server/http/announcements.ts similarity index 70% rename from server/routes/announcements.ts rename to server/http/announcements.ts index 897e6515..48fa9fc5 100644 --- a/server/routes/announcements.ts +++ b/server/http/announcements.ts @@ -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() .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() .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 }) }) diff --git a/server/routes/audit-events.integration.test.ts b/server/http/audit-events.integration.test.ts similarity index 99% rename from server/routes/audit-events.integration.test.ts rename to server/http/audit-events.integration.test.ts index 1864b90e..fba7e4cc 100644 --- a/server/routes/audit-events.integration.test.ts +++ b/server/http/audit-events.integration.test.ts @@ -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>['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 } diff --git a/server/routes/audit.integration.test.ts b/server/http/audit.integration.test.ts similarity index 91% rename from server/routes/audit.integration.test.ts rename to server/http/audit.integration.test.ts index 296efa49..87d81e90 100644 --- a/server/routes/audit.integration.test.ts +++ b/server/http/audit.integration.test.ts @@ -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) diff --git a/server/routes/audit.ts b/server/http/audit.ts similarity index 85% rename from server/routes/audit.ts rename to server/http/audit.ts index 65ee28f9..6dbf4a09 100644 --- a/server/routes/audit.ts +++ b/server/http/audit.ts @@ -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() .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, diff --git a/server/routes/auth-providers.integration.test.ts b/server/http/auth-providers.integration.test.ts similarity index 89% rename from server/routes/auth-providers.integration.test.ts rename to server/http/auth-providers.integration.test.ts index 86a1c8d6..b79ed537 100644 --- a/server/routes/auth-providers.integration.test.ts +++ b/server/http/auth-providers.integration.test.ts @@ -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) diff --git a/server/routes/auth-providers.ts b/server/http/auth-providers.ts similarity index 76% rename from server/routes/auth-providers.ts rename to server/http/auth-providers.ts index 8f47dde7..9ef195be 100644 --- a/server/routes/auth-providers.ts +++ b/server/http/auth-providers.ts @@ -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().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().get('/', async (c) => { export const adminAuthProviders = new Hono() .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() 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() 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() 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 }) }) diff --git a/server/routes/auth-username.integration.test.ts b/server/http/auth-username.integration.test.ts similarity index 93% rename from server/routes/auth-username.integration.test.ts rename to server/http/auth-username.integration.test.ts index d6401574..9152ca49 100644 --- a/server/routes/auth-username.integration.test.ts +++ b/server/http/auth-username.integration.test.ts @@ -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', diff --git a/server/routes/auth.cf-test.ts b/server/http/auth.cf-test.ts similarity index 100% rename from server/routes/auth.cf-test.ts rename to server/http/auth.cf-test.ts diff --git a/server/routes/auth.integration.test.ts b/server/http/auth.integration.test.ts similarity index 100% rename from server/routes/auth.integration.test.ts rename to server/http/auth.integration.test.ts diff --git a/server/routes/background-jobs.integration.test.ts b/server/http/background-jobs.integration.test.ts similarity index 85% rename from server/routes/background-jobs.integration.test.ts rename to server/http/background-jobs.integration.test.ts index 7b2bbe2a..554f58b0 100644 --- a/server/routes/background-jobs.integration.test.ts +++ b/server/http/background-jobs.integration.test.ts @@ -1,14 +1,10 @@ import { sql } from 'drizzle-orm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { ARCHIVE_QUEUE_BINDING, type ArchiveJobMessage, runArchiveJobMessage } from '../services/archive-jobs' -import { - cancelBackgroundJob, - createBackgroundJob, - getBackgroundJob, - updateBackgroundJob, -} from '../services/background-jobs' -import { S3Service } from '../services/s3' +import { ARCHIVE_QUEUE_BINDING, createArchiveJobsGateway } from '../adapters/gateways/archive-jobs' +import { S3Service } from '../adapters/gateways/s3' +import { createBackgroundJobRepo } from '../adapters/repos/background-job' import { authedHeaders, createTestApp } from '../test/setup.js' +import type { ArchiveJobMessage } from '../usecases/ports' type TestDb = Awaited>['db'] @@ -34,7 +30,7 @@ describe('background jobs API', () => { vi.restoreAllMocks() }) - it('creates archive jobs through POST and completes them after the response', async () => { + it('creates archive jobs through POST and completes them after the response [spec: background-jobs/create-and-complete]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app, 'jobs-create@example.com') const { orgId } = await getUserOrg(db, 'jobs-create@example.com') @@ -97,7 +93,7 @@ describe('background jobs API', () => { expect(putKeys).toHaveLength(1) }) - it('dispatches archive jobs to Cloudflare Queue bindings and lets the consumer complete them', async () => { + it('dispatches archive jobs to Cloudflare Queue bindings and lets the consumer complete them [spec: background-jobs/queue-dispatch]', async () => { const messages: ArchiveJobMessage[] = [] const queue = { send: async (message: ArchiveJobMessage) => messages.push(message) } const { app, db, platform } = await createTestApp({}, { [ARCHIVE_QUEUE_BINDING]: queue }) @@ -147,17 +143,17 @@ describe('background jobs API', () => { const created = (await res.json()) as { id: string; status: string } expect(created.status).toBe('queued') expect(messages).toHaveLength(1) - await expect(getBackgroundJob(db, orgId, created.id)).resolves.toMatchObject({ status: 'queued' }) + await expect(createBackgroundJobRepo(db).get(orgId, created.id)).resolves.toMatchObject({ status: 'queued' }) - await runArchiveJobMessage(platform, messages[0]) + await createArchiveJobsGateway(platform).runMessage(messages[0]) - await expect(getBackgroundJob(db, orgId, created.id)).resolves.toMatchObject({ + await expect(createBackgroundJobRepo(db).get(orgId, created.id)).resolves.toMatchObject({ status: 'completed', progress: { outputBytes: 2, fileCount: 1 }, }) }) - it('returns a failed archive job for a missing explicit target folder', async () => { + it('returns a failed archive job for a missing explicit target folder [spec: background-jobs/missing-target]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app, 'jobs-missing-target@example.com') const { orgId } = await getUserOrg(db, 'jobs-missing-target@example.com') @@ -191,7 +187,7 @@ describe('background jobs API', () => { }) }) - it('returns a failed archive job when explicit target folder points to a file', async () => { + it('returns a failed archive job when explicit target folder points to a file [spec: background-jobs/target-is-file]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app, 'jobs-file-target@example.com') const { orgId } = await getUserOrg(db, 'jobs-file-target@example.com') @@ -229,13 +225,13 @@ describe('background jobs API', () => { }) }) - it('lists current org jobs with status/type filters and pagination', async () => { + it('lists current org jobs with status/type filters and pagination [spec: background-jobs/list-filter]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app, 'jobs-list@example.com') const { orgId, userId } = await getUserOrg(db, 'jobs-list@example.com') - await createBackgroundJob(db, { orgId, userId, type: 'archive_compress' }) - const running = await createBackgroundJob(db, { orgId, userId, type: 'archive_extract' }) - await updateBackgroundJob(db, orgId, running.id, { status: 'running' }) + await createBackgroundJobRepo(db).create({ orgId, userId, type: 'archive_compress' }) + const running = await createBackgroundJobRepo(db).create({ orgId, userId, type: 'archive_extract' }) + await createBackgroundJobRepo(db).update(orgId, running.id, { status: 'running' }) const res = await app.request('/api/background-jobs?status=running&type=archive_extract&page=1&pageSize=1', { headers, @@ -250,12 +246,16 @@ describe('background jobs API', () => { }) }) - it('rejects detail access across organizations', async () => { + it('rejects detail access across organizations [spec: background-jobs/cross-org-guard]', async () => { const { app, db } = await createTestApp() await authedHeaders(app, 'jobs-owner@example.com') const viewerHeaders = await authedHeaders(app, 'jobs-viewer@example.com') const owner = await getUserOrg(db, 'jobs-owner@example.com') - const job = await createBackgroundJob(db, { orgId: owner.orgId, userId: owner.userId, type: 'archive_compress' }) + const job = await createBackgroundJobRepo(db).create({ + orgId: owner.orgId, + userId: owner.userId, + type: 'archive_compress', + }) const res = await app.request(`/api/background-jobs/${job.id}`, { headers: viewerHeaders }) @@ -263,13 +263,13 @@ describe('background jobs API', () => { await expect(res.json()).resolves.toEqual({ error: 'Not found' }) }) - it('cancels only queued or running jobs', async () => { + it('cancels only queued or running jobs [spec: background-jobs/cancel]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app, 'jobs-cancel@example.com') const { orgId, userId } = await getUserOrg(db, 'jobs-cancel@example.com') - const queued = await createBackgroundJob(db, { orgId, userId, type: 'archive_compress' }) - const completed = await createBackgroundJob(db, { orgId, userId, type: 'archive_extract' }) - await updateBackgroundJob(db, orgId, completed.id, { status: 'completed' }) + const queued = await createBackgroundJobRepo(db).create({ orgId, userId, type: 'archive_compress' }) + const completed = await createBackgroundJobRepo(db).create({ orgId, userId, type: 'archive_extract' }) + await createBackgroundJobRepo(db).update(orgId, completed.id, { status: 'completed' }) const canceledRes = await app.request(`/api/background-jobs/${queued.id}/cancel`, { method: 'POST', headers }) const rejectedRes = await app.request(`/api/background-jobs/${completed.id}/cancel`, { method: 'POST', headers }) @@ -280,19 +280,24 @@ describe('background jobs API', () => { await expect(rejectedRes.json()).resolves.toEqual({ error: 'Background job cannot be canceled' }) }) - it('retries only failed retryable jobs without hiding the failed job', async () => { + it('retries only failed retryable jobs without hiding the failed job [spec: background-jobs/retry]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app, 'jobs-retry@example.com') const { orgId, userId } = await getUserOrg(db, 'jobs-retry@example.com') - const retryable = await createBackgroundJob(db, { + const retryable = await createBackgroundJobRepo(db).create({ orgId, userId, type: 'archive_extract', targetPath: '/imports/archive.zip', retryable: true, }) - const notFailed = await createBackgroundJob(db, { orgId, userId, type: 'archive_compress', retryable: true }) - await updateBackgroundJob(db, orgId, retryable.id, { + const notFailed = await createBackgroundJobRepo(db).create({ + orgId, + userId, + type: 'archive_compress', + retryable: true, + }) + await createBackgroundJobRepo(db).update(orgId, retryable.id, { status: 'failed', errorMessage: 'zip_crc_error', progress: { inputBytes: 128, fileCount: 4 }, @@ -308,16 +313,18 @@ describe('background jobs API', () => { expect(rejectedRes.status).toBe(409) await expect(rejectedRes.json()).resolves.toEqual({ error: 'Background job cannot be retried' }) - const original = await getBackgroundJob(db, orgId, retryable.id) + const original = await createBackgroundJobRepo(db).get(orgId, retryable.id) expect(original).toMatchObject({ status: 'failed', errorMessage: 'zip_crc_error', retriedFromJobId: null }) - await expect(cancelBackgroundJob(db, orgId, retryable.id)).rejects.toMatchObject({ code: 'not_cancelable' }) + await expect(createBackgroundJobRepo(db).cancel(orgId, retryable.id)).rejects.toMatchObject({ + code: 'not_cancelable', + }) }) - it('lets non-domain service errors surface at the route boundary', async () => { + it('lets non-domain service errors surface at the route boundary [spec: background-jobs/error-surfacing]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app, 'jobs-invalid-json@example.com') const { orgId, userId } = await getUserOrg(db, 'jobs-invalid-json@example.com') - const job = await createBackgroundJob(db, { orgId, userId, type: 'archive_compress' }) + const job = await createBackgroundJobRepo(db).create({ orgId, userId, type: 'archive_compress' }) await db.run(sql`UPDATE background_jobs SET metadata = '{invalid-json' WHERE id = ${job.id}`) const res = await app.request(`/api/background-jobs/${job.id}`, { headers }) @@ -339,9 +346,9 @@ async function waitForJob( orgId: string, jobId: string, status: 'completed' | 'failed', -): Promise>> { +): Promise['get']>>> { for (let i = 0; i < 20; i++) { - const job = await getBackgroundJob(db, orgId, jobId) + const job = await createBackgroundJobRepo(db).get(orgId, jobId) if (job.status === status) return job await new Promise((resolve) => setTimeout(resolve, 10)) } diff --git a/server/routes/background-jobs.ts b/server/http/background-jobs.ts similarity index 76% rename from server/routes/background-jobs.ts rename to server/http/background-jobs.ts index 59137ad2..1cca40c0 100644 --- a/server/routes/background-jobs.ts +++ b/server/http/background-jobs.ts @@ -5,25 +5,17 @@ import { Hono } from 'hono' import { createBackgroundJobRequestSchema, listBackgroundJobsQuerySchema } from '../../shared/schemas' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { dispatchArchiveJob } from '../services/archive-jobs' -import { enqueueArchiveJob } from '../services/archive-processing' -import { - BackgroundJobError, - cancelBackgroundJob, - getBackgroundJob, - listBackgroundJobs, - retryBackgroundJob, -} from '../services/background-jobs' +import { enqueueArchiveJob } from '../usecases/archive-processing' +import { BackgroundJobError } from '../usecases/ports' const backgroundJobs = new Hono() .use(requireAuth) .get('/', zValidator('query', listBackgroundJobsQuerySchema), async (c) => { - const db = c.get('platform').db const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No organization found' }, 404) const query = c.req.valid('query') - const result = await listBackgroundJobs(db, orgId, query) + const result = await c.get('deps').backgroundJobs.list(orgId, query) return c.json({ ...result, page: query.page, pageSize: query.pageSize }) }) .post('/', zValidator('json', createBackgroundJobRequestSchema), async (c) => @@ -33,14 +25,13 @@ const backgroundJobs = new Hono() const orgId = requireOrg(c) const userId = c.get('userId') if (!userId) throw new BackgroundJobError('not_found') - const db = c.get('platform').db const request = c.req.valid('json') - const job = await enqueueArchiveJob(db, { + const job = await enqueueArchiveJob(c.get('deps'), { orgId, userId, request, }) - await dispatchArchiveJob(c.get('platform'), { orgId, userId, request, jobId: job.id }) + await c.get('deps').archiveJobs.dispatch({ orgId, userId, request, jobId: job.id }) return job }, 201, @@ -49,13 +40,13 @@ const backgroundJobs = new Hono() .get('/:id', async (c) => backgroundJobResponse(c, async () => { const orgId = requireOrg(c) - return getBackgroundJob(c.get('platform').db, orgId, c.req.param('id')) + return c.get('deps').backgroundJobs.get(orgId, c.req.param('id')) }), ) .post('/:id/cancel', async (c) => backgroundJobResponse(c, async () => { const orgId = requireOrg(c) - return cancelBackgroundJob(c.get('platform').db, orgId, c.req.param('id')) + return c.get('deps').backgroundJobs.cancel(orgId, c.req.param('id')) }), ) .post('/:id/retry', async (c) => @@ -63,11 +54,10 @@ const backgroundJobs = new Hono() c, async () => { const orgId = requireOrg(c) - const db = c.get('platform').db - const job = await retryBackgroundJob(db, orgId, c.req.param('id')) + const job = await c.get('deps').backgroundJobs.retry(orgId, c.req.param('id')) const request = createBackgroundJobRequestSchema.safeParse(job.metadata) if (request.success) { - await dispatchArchiveJob(c.get('platform'), { + await c.get('deps').archiveJobs.dispatch({ orgId, userId: job.userId, request: request.data, diff --git a/server/routes/branding.integration.test.ts b/server/http/branding.integration.test.ts similarity index 91% rename from server/routes/branding.integration.test.ts rename to server/http/branding.integration.test.ts index d9aed7f4..624ec87a 100644 --- a/server/routes/branding.integration.test.ts +++ b/server/http/branding.integration.test.ts @@ -1,7 +1,7 @@ import { sql } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrandingConfig } from '../../shared/types' -import { S3Service } from '../services/s3.js' +import { S3Service } from '../adapters/gateways/s3.js' import { adminHeaders, authedHeaders, createTestApp, seedProLicense as seedProLicenseRow } from '../test/setup.js' // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -27,7 +27,7 @@ async function seedBrandingOption(db: Awaited>[ // ─── GET /api/branding ──────────────────────────────────────────────────────── describe('GET /api/branding', () => { - it('returns defaults when no branding configured', async () => { + it('returns defaults when no branding configured [spec: branding/defaults]', async () => { const { app } = await createTestApp() const res = await app.request('/api/branding') expect(res.status).toBe(200) @@ -46,13 +46,13 @@ describe('GET /api/branding', () => { }) }) - it('is accessible without authentication', async () => { + it('is accessible without authentication [spec: branding/public]', async () => { const { app } = await createTestApp() const res = await app.request('/api/branding') expect(res.status).toBe(200) }) - it('returns stored branding values when set', async () => { + it('returns stored branding values when set [spec: branding/stored-values]', async () => { const { app, db } = await createTestApp() await seedBrandingOption(db, 'branding_wordmark_text', 'MyCloud') await seedBrandingOption(db, 'branding_hide_powered_by', 'true') @@ -80,7 +80,7 @@ describe('GET /api/branding', () => { }) }) - it('returns stored custom theme values when set', async () => { + it('returns stored custom theme values when set [spec: branding/custom-theme]', async () => { const { app, db } = await createTestApp() await seedBrandingOption(db, 'branding_theme_mode', 'custom') await seedBrandingOption(db, 'branding_theme_primary_color', '#123456') @@ -124,7 +124,7 @@ describe('PUT /api/admin/branding', () => { expect(res.status).toBe(401) }) - it('returns 403 for non-admin', async () => { + it('returns 403 for non-admin [spec: branding/admin-only]', async () => { const { app } = await createTestApp() // First user is auto-promoted to admin — create them first, then the non-admin await adminHeaders(app) @@ -133,7 +133,7 @@ describe('PUT /api/admin/branding', () => { expect(res.status).toBe(403) }) - it('returns 402 when white_label feature is not available (no Pro)', async () => { + it('returns 402 when white_label feature is not available (no Pro) [spec: branding/white-label-gated]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/branding', { method: 'PUT', headers }) @@ -142,7 +142,7 @@ describe('PUT /api/admin/branding', () => { expect(body.feature).toBe('white_label') }) - it('returns 415 when body is not multipart', async () => { + it('returns 415 when body is not multipart [spec: branding/multipart-required]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -154,7 +154,7 @@ describe('PUT /api/admin/branding', () => { expect(res.status).toBe(415) }) - it('returns 422 when wordmark_text exceeds 24 chars', async () => { + it('returns 422 when wordmark_text exceeds 24 chars [spec: branding/wordmark-length]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -165,7 +165,7 @@ describe('PUT /api/admin/branding', () => { expect(res.status).toBe(422) }) - it('saves wordmark_text and hide_powered_by without file upload', async () => { + it('saves wordmark_text and hide_powered_by without file upload [spec: branding/save-text]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -187,7 +187,7 @@ describe('PUT /api/admin/branding', () => { expect(getBody.hide_powered_by).toBe(true) }) - it('saves a built-in theme selection', async () => { + it('saves a built-in theme selection [spec: branding/builtin-theme]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -206,7 +206,7 @@ describe('PUT /api/admin/branding', () => { expect(getBody.theme).toMatchObject({ mode: 'preset', preset: 'ocean', configured: true }) }) - it('saves custom theme colors when valid', async () => { + it('saves custom theme colors when valid [spec: branding/save-custom-theme]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -231,7 +231,7 @@ describe('PUT /api/admin/branding', () => { }) }) - it('returns 422 for invalid custom colors without changing stored theme', async () => { + it('returns 422 for invalid custom colors without changing stored theme [spec: branding/invalid-colors]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -267,7 +267,7 @@ describe('PUT /api/admin/branding', () => { expect(res.status).toBe(422) }) - it('uploads logo file to S3 and stores URL', async () => { + it('uploads logo file to S3 and stores URL [spec: branding/logo-upload]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -284,7 +284,7 @@ describe('PUT /api/admin/branding', () => { expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1) }) - it('returns 400 for invalid logo MIME type', async () => { + it('returns 400 for invalid logo MIME type [spec: branding/logo-mime]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -298,7 +298,7 @@ describe('PUT /api/admin/branding', () => { expect(res.status).toBe(400) }) - it('returns 413 for logo file exceeding 2MB', async () => { + it('returns 413 for logo file exceeding 2MB [spec: branding/logo-size]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -314,7 +314,7 @@ describe('PUT /api/admin/branding', () => { expect(res.status).toBe(413) }) - it('returns 503 when no public storage is configured', async () => { + it('returns 503 when no public storage is configured [spec: branding/logo-needs-storage]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) @@ -345,7 +345,7 @@ describe('DELETE /api/admin/branding/:field', () => { expect(res.status).toBe(402) }) - it('resets a text field', async () => { + it('resets a text field [spec: branding/reset-field]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedProLicense(db) diff --git a/server/routes/branding.ts b/server/http/branding.ts similarity index 86% rename from server/routes/branding.ts rename to server/http/branding.ts index f64c413d..1e304060 100644 --- a/server/routes/branding.ts +++ b/server/http/branding.ts @@ -3,7 +3,6 @@ import { type BrandingField, type BrandingThemeMode, isBrandingThemePresetId } f import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' import { requireFeature } from '../middleware/require-feature' -import { recordActivity } from '../services/activity' import { type BRANDING_KEYS, readBranding, @@ -11,7 +10,7 @@ import { resetBrandingTheme, setBrandingField, uploadBrandingImage, -} from '../services/branding' +} from '../usecases/branding' type StoredBrandingField = keyof typeof BRANDING_KEYS type ThemeField = @@ -85,8 +84,7 @@ function parseThemeUpdate(form: FormData): { ok: true; values: ThemeUpdate } | { // Public — no auth required. Used on sign-in/sign-up pages too. export const publicBranding = new Hono().get('/', async (c) => { - const db = c.get('platform').db - const config = await readBranding(db) + const config = await readBranding(c.get('deps')) return c.json(config) }) @@ -95,7 +93,7 @@ export const brandingAdmin = new Hono() .use(requireAdmin) .use(requireFeature('white_label')) .put('/', async (c) => { - const platform = c.get('platform') + const deps = c.get('deps') const userId = c.get('userId')! const orgId = c.get('orgId')! @@ -112,14 +110,14 @@ export const brandingAdmin = new Hono() const logoFile = form.get('logo') if (logoFile instanceof File && logoFile.size > 0) { - const result = await uploadBrandingImage(platform, 'logo', logoFile) + const result = await uploadBrandingImage(deps, 'logo', logoFile) if (!result.ok) return c.json({ error: result.error }, result.status) changedFields.push('logo') } const faviconFile = form.get('favicon') if (faviconFile instanceof File && faviconFile.size > 0) { - const result = await uploadBrandingImage(platform, 'favicon', faviconFile) + const result = await uploadBrandingImage(deps, 'favicon', faviconFile) if (!result.ok) return c.json({ error: result.error }, result.status) changedFields.push('favicon') } @@ -127,24 +125,24 @@ export const brandingAdmin = new Hono() const wordmarkRaw = form.get('wordmark_text') if (typeof wordmarkRaw === 'string') { if (wordmarkRaw.length > 24) return c.json({ error: 'wordmark_text must be 24 characters or fewer' }, 422) - await setBrandingField(platform.db, 'wordmark_text', wordmarkRaw) + await setBrandingField(deps, 'wordmark_text', wordmarkRaw) changedFields.push('wordmark_text') } const hidePoweredByRaw = form.get('hide_powered_by') if (hidePoweredByRaw !== null) { const value = hidePoweredByRaw === 'true' || hidePoweredByRaw === '1' ? 'true' : 'false' - await setBrandingField(platform.db, 'hide_powered_by', value) + await setBrandingField(deps, 'hide_powered_by', value) changedFields.push('hide_powered_by') } for (const [field, value] of Object.entries(themeUpdate.values) as [ThemeField, string][]) { - await setBrandingField(platform.db, field, value) + await setBrandingField(deps, field, value) changedFields.push(field) } if (changedFields.length > 0) { - await recordActivity(platform.db, { + await deps.activity.record({ orgId, userId, action: 'branding_update', @@ -154,10 +152,10 @@ export const brandingAdmin = new Hono() }) } - return c.json(await readBranding(platform.db)) + return c.json(await readBranding(deps)) }) .delete('/:field', async (c) => { - const platform = c.get('platform') + const deps = c.get('deps') const userId = c.get('userId')! const orgId = c.get('orgId')! const rawField = c.req.param('field') @@ -165,11 +163,11 @@ export const brandingAdmin = new Hono() return c.json({ error: `Invalid field. Valid fields: ${[...VALID_RESET_FIELDS].join(', ')}` }, 400) } if (rawField.startsWith('theme')) { - await resetBrandingTheme(platform.db) + await resetBrandingTheme(deps) } else { - await resetBrandingField(platform.db, rawField as StoredBrandingField) + await resetBrandingField(deps, rawField as StoredBrandingField) } - await recordActivity(platform.db, { + await deps.activity.record({ orgId, userId, action: 'branding_reset', diff --git a/server/routes/cloud-store-helpers.test.ts b/server/http/cloud-store-helpers.test.ts similarity index 100% rename from server/routes/cloud-store-helpers.test.ts rename to server/http/cloud-store-helpers.test.ts diff --git a/server/routes/cloud-store-helpers.ts b/server/http/cloud-store-helpers.ts similarity index 91% rename from server/routes/cloud-store-helpers.ts rename to server/http/cloud-store-helpers.ts index e0ff2ea1..41f06847 100644 --- a/server/routes/cloud-store-helpers.ts +++ b/server/http/cloud-store-helpers.ts @@ -13,14 +13,13 @@ import { } from 'zpan-cloud-sdk' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import type { Env } from '../middleware/platform' -import type { Database } from '../platform/interface' -import { getCloudStoreBinding } from '../services/cloud-store' -import { createBoundCloudClient } from '../services/licensing-cloud' +import type { CloudStoreRepo } from '../usecases/ports' const CLOUD_STORE_REQUEST_TIMEOUT_MS = 10_000 export type RouteContext = { get(key: 'platform'): Env['Variables']['platform'] + get(key: 'deps'): Env['Variables']['deps'] req: { url: string; header(name: string): string | undefined } } @@ -65,9 +64,9 @@ export const cloudGiftCardCreateResponseSchema = z .transform((response) => (Array.isArray(response) ? response : response.items)) export const giftCardListQuerySchema = z.object({ status: giftCardStatusSchema.optional() }) -export async function getUserStoreSettings(db: Database) { +export async function getUserStoreSettings(cloudStore: CloudStoreRepo) { try { - await getCloudStoreBinding(db) + await cloudStore.getCloudStoreBinding() return { ready: true } } catch (error) { const message = (error as Error).message @@ -77,9 +76,9 @@ export async function getUserStoreSettings(db: Database) { } export async function getBoundCloudClient(c: RouteContext) { - const binding = await getCloudStoreBinding(c.get('platform').db) + const binding = await c.get('deps').cloudStore.getCloudStoreBinding() return { - client: createBoundCloudClient(getCloudBaseUrl(c), binding.refreshToken), + client: c.get('deps').licensingCloud.createBoundCloudClient(getCloudBaseUrl(c), binding.refreshToken), storeId: binding.storeId, } } diff --git a/server/routes/cloud-store.integration.test.ts b/server/http/cloud-store.integration.test.ts similarity index 97% rename from server/routes/cloud-store.integration.test.ts rename to server/http/cloud-store.integration.test.ts index 996ebe6d..ee7b454d 100644 --- a/server/routes/cloud-store.integration.test.ts +++ b/server/http/cloud-store.integration.test.ts @@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm' import { generateKeys, sign } from 'paseto-ts/v4' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' -import { PUBLIC_KEYS } from '../licensing/public-keys.js' +import { PUBLIC_KEYS } from '../domain/license-keys.js' import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js' import { cloudGiftCardsResponseSchema, cloudPackageResponseSchema } from './cloud-store-helpers.js' @@ -418,7 +418,7 @@ describe('Quota Store API', () => { }) }) - it('returns 402 for Cloud quota-change webhook when Pro quota_store is absent', async () => { + it('returns 402 for Cloud quota-change webhook when Pro quota_store is absent [spec: quota-store/feature-gated]', async () => { const { app } = await createTestApp() const payload = JSON.stringify({ eventId: 'evt-no-pro', @@ -436,7 +436,7 @@ describe('Quota Store API', () => { expect(res.status).toBe(402) }) - it('ignores spoofed forwarded origin for Cloud checkout return URLs', async () => { + it('ignores spoofed forwarded origin for Cloud checkout return URLs [spec: quota-store/checkout-origin-antispoof]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -490,7 +490,7 @@ describe('Quota Store API', () => { }) }) - it('uses the detected site origin for checkout return URLs', async () => { + it('uses the detected site origin for checkout return URLs [spec: quota-store/checkout-return-origin]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -582,7 +582,7 @@ describe('Quota Store API', () => { }) }) - it('rejects checkout target orgs the user cannot access', async () => { + it('rejects checkout target orgs the user cannot access [spec: quota-store/checkout-access]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -597,7 +597,7 @@ describe('Quota Store API', () => { expect(res.status).toBe(200) }) - it('rejects team checkout from non-owner members', async () => { + it('rejects team checkout from non-owner members [spec: quota-store/team-checkout-owner-only]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const { headers } = await memberInTeamOrg(app, db, 'editor') @@ -612,7 +612,7 @@ describe('Quota Store API', () => { expect(res.status).toBe(403) }) - it('allows team checkout for the team owner and targets the team org', async () => { + it('allows team checkout for the team owner and targets the team org [spec: quota-store/team-checkout]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const { headers, teamOrgId } = await memberInTeamOrg(app, db, 'owner') @@ -706,7 +706,7 @@ describe('Quota Store API', () => { expect(checkout.status).toBe(200) }) - it('rejects recurring checkout when the workspace already has an active plan', async () => { + it('rejects recurring checkout when the workspace already has an active plan [spec: quota-store/no-double-plan]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -755,7 +755,7 @@ describe('Quota Store API', () => { expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1) }) - it('creates fixed-duration package checkouts without credit discount fields', async () => { + it('creates fixed-duration package checkouts without credit discount fields [spec: quota-store/fixed-checkout]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -770,7 +770,7 @@ describe('Quota Store API', () => { expect(checkout.status).toBe(200) }) - it('creates a subscription portal for the active workspace plan', async () => { + it('creates a subscription portal for the active workspace plan [spec: quota-store/subscription-portal]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -797,7 +797,7 @@ describe('Quota Store API', () => { expect(JSON.parse(String(init.body))).toEqual({ customerId: orgId, returnUrl: 'http://localhost/storage' }) }) - it('lists purchasable packages, targets, checkout, and orders', async () => { + it('lists purchasable packages, targets, checkout, and orders [spec: quota-store/list-packages]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -873,7 +873,7 @@ describe('Quota Store API', () => { expect(parsedOrdersUrl.searchParams.get('customerId')).toBe(orgId) }) - it('rejects checkout currency fields before proxying to Cloud', async () => { + it('rejects checkout currency fields before proxying to Cloud [spec: quota-store/checkout-currency-guard]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -926,7 +926,7 @@ describe('Quota Store API', () => { expect(calls.some(([url, init]) => init.method === 'POST' && String(url).endsWith('/orders'))).toBe(false) }) - it('proxies credit balance and gift card redemption through credit endpoints', async () => { + it('proxies credit balance and gift card redemption through credit endpoints [spec: quota-store/credit-balance]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -962,7 +962,7 @@ describe('Quota Store API', () => { expect(JSON.parse(String(redeemInit.body))).toEqual({ codes: ['ZS-1234-5678'] }) }) - it('proxies credit ledger entries through credit endpoints', async () => { + it('proxies credit ledger entries through credit endpoints [spec: quota-store/credit-ledger]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -1004,7 +1004,7 @@ describe('Quota Store API', () => { expect(ledgerInit.method).toBe('GET') }) - it('continues payment and cancels orders through Cloud', async () => { + it('continues payment and cancels orders through Cloud [spec: quota-store/order-continue-cancel]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -1061,7 +1061,7 @@ describe('Quota Store API', () => { expect(JSON.parse(String(cancelInit.body))).toEqual({ status: 'canceled' }) }) - it('rejects payment continuation and cancellation for another org order', async () => { + it('rejects payment continuation and cancellation for another org order [spec: quota-store/order-org-scope]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -1087,7 +1087,7 @@ describe('Quota Store API', () => { ).toBe(false) }) - it('hides self-service store endpoints until Cloud is bound', async () => { + it('hides self-service store endpoints until Cloud is bound [spec: quota-store/requires-binding]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -1130,7 +1130,7 @@ describe('Quota Store API', () => { await expect(res.json()).resolves.toEqual({ error: 'invalid_cloud_response' }) }) - it('surfaces Cloud checkout error responses', async () => { + it('surfaces Cloud checkout error responses [spec: quota-store/checkout-error-surfacing]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') @@ -1225,7 +1225,7 @@ describe('Quota Store API', () => { await expect(res.json()).resolves.toMatchObject({ success: true, duplicate: false }) }) - it('valid Cloud quota-change webhook records active entitlement once and records audit', async () => { + it('valid Cloud quota-change webhook records active entitlement once and records audit [spec: quota-store/webhook-records-entitlement]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await adminHeaders(app) @@ -1280,7 +1280,7 @@ describe('Quota Store API', () => { }) }) - it('delivers initial subscription storage and traffic entitlements under a stable source id', async () => { + it('delivers initial subscription storage and traffic entitlements under a stable source id [spec: quota-store/webhook-subscription-delivery]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await adminHeaders(app) @@ -1372,7 +1372,7 @@ describe('Quota Store API', () => { }) }) - it('renews subscription entitlements by replacing plan bytes and extending expiry', async () => { + it('renews subscription entitlements by replacing plan bytes and extending expiry [spec: quota-store/webhook-renewal]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await adminHeaders(app) @@ -1448,7 +1448,7 @@ describe('Quota Store API', () => { }) }) - it('accumulates repeated Cloud increases for the same order and resource', async () => { + it('accumulates repeated Cloud increases for the same order and resource [spec: quota-store/webhook-accumulate]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await adminHeaders(app) @@ -1493,7 +1493,7 @@ describe('Quota Store API', () => { expect(quota.entitlementQuota).toBe(6144) }) - it('decreases accumulated Cloud order entitlement bytes without revoking the remainder', async () => { + it('decreases accumulated Cloud order entitlement bytes without revoking the remainder [spec: quota-store/webhook-decrease]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const headers = await adminHeaders(app) @@ -1815,7 +1815,7 @@ describe('Quota Store API', () => { expect(rows[0].quota).toBe(8192) }) - it('replaying the same decrease event is idempotent and does not double-deduct', async () => { + it('replaying the same decrease event is idempotent and does not double-deduct [spec: quota-store/webhook-idempotent]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) @@ -2018,7 +2018,7 @@ describe('Quota Store API', () => { expect(deliveries).toEqual([{ status: 'processed', error: null }]) }) - it('rejects missing Cloud quota-change webhook auth', async () => { + it('rejects missing Cloud quota-change webhook auth [spec: quota-store/webhook-auth-required]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) @@ -2067,7 +2067,7 @@ describe('Quota Store API', () => { expect(res.status).toBe(401) }) - it('rejects expired Cloud quota-change webhook event tokens', async () => { + it('rejects expired Cloud quota-change webhook event tokens [spec: quota-store/webhook-token-expiry]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-expired-token' }) @@ -2181,7 +2181,7 @@ describe('Quota Store API', () => { expect(res.status).toBe(401) }) - it('rejects Cloud quota-change webhook event tokens with the wrong audience', async () => { + it('rejects Cloud quota-change webhook event tokens with the wrong audience [spec: quota-store/webhook-token-audience]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-wrong-audience' }) @@ -2225,7 +2225,7 @@ describe('Quota Store API', () => { await expect(res.json()).resolves.toMatchObject({ error: 'invalid_payload' }) }) - it('rejects credit-only commerce fulfillment events on the quota webhook', async () => { + it('rejects credit-only commerce fulfillment events on the quota webhook [spec: quota-store/webhook-rejects-commerce]', async () => { const { app, db } = await createTestApp() await seedBusinessLicense(db) const payload = JSON.stringify({ @@ -2364,7 +2364,7 @@ async function signedWebhookHeaders(payload: string, overrides: Record>['db']) { - const { createLicenseBinding } = await import('../licensing/license-state.js') + const { createLicenseBindingRepo } = await import('../adapters/repos/license-binding.js') const issuedAt = Math.floor(Date.now() / 1000) const expiresAt = issuedAt + 3600 const cachedCert = sign(EVENT_SECRET, { @@ -2392,7 +2392,7 @@ async function seedCloudPr16License(db: Awaited expiresAt, }) - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'binding_1', cloudStoreId: 'store-binding-1', instanceId: 'license_1', diff --git a/server/routes/cloud-store.ts b/server/http/cloud-store.ts similarity index 100% rename from server/routes/cloud-store.ts rename to server/http/cloud-store.ts diff --git a/server/routes/cloud-store/shared.ts b/server/http/cloud-store/shared.ts similarity index 85% rename from server/routes/cloud-store/shared.ts rename to server/http/cloud-store/shared.ts index 86b1fc4f..71b0dea0 100644 --- a/server/routes/cloud-store/shared.ts +++ b/server/http/cloud-store/shared.ts @@ -1,5 +1,6 @@ import type { z } from 'zod' -import { getSitePublicOrigin, originFromRequestUrl } from '../../services/site-public-origin' +import { originFromRequestUrl } from '../../domain/site-public-origin' +import { getSitePublicOrigin } from '../../usecases/site-public-origin' import { cloudOrdersResponseSchema, getBoundCloudClient, @@ -34,7 +35,7 @@ export async function getCloudOrders( } export async function getInstanceOrigin(c: RouteContext): Promise { - const configuredOrigin = await getSitePublicOrigin(c.get('platform').db) + const configuredOrigin = await getSitePublicOrigin(c.get('deps')) if (configuredOrigin) return configuredOrigin return originFromRequestUrl(c.req.url) ?? new URL(c.req.url).origin } diff --git a/server/routes/cloud-store/storefront.ts b/server/http/cloud-store/storefront.ts similarity index 90% rename from server/routes/cloud-store/storefront.ts rename to server/http/cloud-store/storefront.ts index e6f4b60a..bdfae249 100644 --- a/server/routes/cloud-store/storefront.ts +++ b/server/http/cloud-store/storefront.ts @@ -12,8 +12,6 @@ import { z } from 'zod' import { requireAuth, requireTeamRole } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { requireFeature } from '../../middleware/require-feature' -import { getAccessibleTargets, getCustomerLabel } from '../../services/cloud-store' -import { getEffectiveQuota } from '../../services/effective-quota' import { cloudBillingPortalSessionResponseSchema, cloudCheckoutResponseSchema, @@ -34,7 +32,7 @@ export const cloudStore = new Hono() .use(requireAuth) .use(requireFeature('quota_store')) .get('/packages', async (c) => { - const store = await getUserStoreSettings(c.get('platform').db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( @@ -50,7 +48,7 @@ export const cloudStore = new Hono() return c.json({ ...result, items, total: items.length }) }) .get('/credits/products', async (c) => { - const store = await getUserStoreSettings(c.get('platform').db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( @@ -66,16 +64,15 @@ export const cloudStore = new Hono() return c.json({ ...result, items, total: items.length }) }) .get('/targets', async (c) => { - const db = c.get('platform').db - const store = await getUserStoreSettings(db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) - const items = await getAccessibleTargets(db, c.get('userId')!) + const items = await c.get('deps').cloudStore.getAccessibleTargets(c.get('userId')!) return c.json({ items, total: items.length }) }) .get('/credits', requireTeamRole('owner'), async (c) => { const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) - const store = await getUserStoreSettings(c.get('platform').db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( @@ -91,7 +88,7 @@ export const cloudStore = new Hono() .get('/credits/ledger-entries', requireTeamRole('owner'), async (c) => { const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) - const store = await getUserStoreSettings(c.get('platform').db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( @@ -108,7 +105,7 @@ export const cloudStore = new Hono() .post('/credits/redemptions', requireTeamRole('owner'), zValidator('json', redeemGiftCardInputSchema), async (c) => { const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) - const store = await getUserStoreSettings(c.get('platform').db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( @@ -124,12 +121,11 @@ export const cloudStore = new Hono() }) .post('/checkouts', requireTeamRole('owner'), zValidator('json', checkoutInputSchema), async (c) => { const body = c.req.valid('json') - const db = c.get('platform').db const userId = c.get('userId')! const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) - const store = await getUserStoreSettings(db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const currency = 'usd' const product = await cloudRequest(c, async ({ client, storeId }) => @@ -148,7 +144,7 @@ export const cloudStore = new Hono() : product.prices.find((item) => item.currency === currency && item.recurring?.usageType !== 'metered') if (!price) return c.json({ error: 'package_price_missing' }, 400) if (price.recurring) { - const quota = await getEffectiveQuota(db, targetOrgId) + const quota = await c.get('deps').quota.getEffectiveQuota(targetOrgId) if (quota.currentPlan?.subscription) return c.json({ error: 'workspace_plan_exists' }, 409) } const origin = await getInstanceOrigin(c) @@ -163,7 +159,7 @@ export const cloudStore = new Hono() target: { orgId: targetOrgId, customerId: targetOrgId, - customerLabel: await getCustomerLabel(db, userId, targetOrgId), + customerLabel: await c.get('deps').cloudStore.getCustomerLabel(userId, targetOrgId), }, }, }), @@ -188,7 +184,7 @@ export const cloudStore = new Hono() return c.json(payment) }) .post('/discount-quotes', zValidator('json', discountQuoteInputSchema), async (c) => { - const store = await getUserStoreSettings(c.get('platform').db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const body = c.req.valid('json') const result = await cloudRequest(c, async ({ client, storeId }) => @@ -204,11 +200,10 @@ export const cloudStore = new Hono() return c.json(result) }) .post('/billing-portal-sessions', requireTeamRole('owner'), async (c) => { - const db = c.get('platform').db const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) - const store = await getUserStoreSettings(db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const origin = await getInstanceOrigin(c) const result = await cloudRequest(c, async ({ client, storeId }) => @@ -224,8 +219,7 @@ export const cloudStore = new Hono() return c.json(result) }) .get('/orders', requireTeamRole('owner'), zValidator('query', cloudStoreOrdersQuerySchema), async (c) => { - const db = c.get('platform').db - const store = await getUserStoreSettings(db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) @@ -235,8 +229,7 @@ export const cloudStore = new Hono() return c.json(result) }) .post('/orders/:orderId/payments', requireTeamRole('owner'), async (c) => { - const db = c.get('platform').db - const store = await getUserStoreSettings(db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) @@ -266,8 +259,7 @@ export const cloudStore = new Hono() requireTeamRole('owner'), zValidator('json', z.object({ status: z.literal('canceled') })), async (c) => { - const db = c.get('platform').db - const store = await getUserStoreSettings(db) + const store = await getUserStoreSettings(c.get('deps').cloudStore) if ('error' in store) return c.json({ error: store.error }, 403) const targetOrgId = c.get('orgId') if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) diff --git a/server/routes/cloud-store/webhooks.ts b/server/http/cloud-store/webhooks.ts similarity index 79% rename from server/routes/cloud-store/webhooks.ts rename to server/http/cloud-store/webhooks.ts index a14e03e4..a6eb4761 100644 --- a/server/routes/cloud-store/webhooks.ts +++ b/server/http/cloud-store/webhooks.ts @@ -1,14 +1,12 @@ import { cloudOrderQuotaChangeSchema } from '@shared/schemas' import { Hono } from 'hono' -import { verifyCloudEventToken } from '../../licensing/cloud-event-token' import type { Env } from '../../middleware/platform' import { requireFeature } from '../../middleware/require-feature' -import { getCloudStoreBinding, processCloudOrderQuotaChange } from '../../services/cloud-store' +import { verifyCloudEventToken } from '../../usecases/license-certificate' import { getCloudBaseUrl, parseJson, sha256Hex } from '../cloud-store-helpers' export const cloudStoreWebhooks = new Hono().use(requireFeature('quota_store')).post('/webhook', async (c) => { - const db = c.get('platform').db - const binding = await getCloudStoreBinding(db) + const binding = await c.get('deps').cloudStore.getCloudStoreBinding() const rawPayload = await c.req.text() const payloadHash = await sha256Hex(rawPayload) const eventToken = c.req.header('x-commerce-event-token') ?? '' @@ -28,7 +26,7 @@ export const cloudStoreWebhooks = new Hono().use(requireFeature('quota_stor if (parsed.data.eventId !== eventAuth.eventId) return c.json({ error: 'invalid_event_token' }, 401) try { - const result = await processCloudOrderQuotaChange(db, parsed.data, rawPayload, payloadHash) + const result = await c.get('deps').cloudStore.processCloudOrderQuotaChange(parsed.data, rawPayload, payloadHash) return c.json({ success: true, duplicate: result.duplicate, eventId: result.eventId }) } catch (error) { return c.json({ error: (error as Error).message }, 400) diff --git a/server/routes/cloud-traffic-metering.integration.test.ts b/server/http/cloud-traffic-metering.integration.test.ts similarity index 94% rename from server/routes/cloud-traffic-metering.integration.test.ts rename to server/http/cloud-traffic-metering.integration.test.ts index 92acc7e9..3f5c93b3 100644 --- a/server/routes/cloud-traffic-metering.integration.test.ts +++ b/server/http/cloud-traffic-metering.integration.test.ts @@ -1,10 +1,10 @@ import { sql } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3' +import { createShareRepo } from '../adapters/repos/share' import { cloudTrafficReports } from '../db/schema' +import { currentTrafficPeriod } from '../domain/quota' import type { Database } from '../platform/interface' -import { currentTrafficPeriod } from '../services/effective-quota' -import { S3Service } from '../services/s3' -import { createShare } from '../services/share' import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup' import { encodeChildRef } from './share-utils' @@ -207,7 +207,12 @@ describe('public redirect cloud traffic reporting', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, 'm-cloud-direct-share') - const share = await createShare(db, { matterId: 'm-cloud-direct-share', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ + matterId: 'm-cloud-direct-share', + orgId, + creatorId, + kind: 'direct', + }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) @@ -230,7 +235,12 @@ describe('public redirect cloud traffic reporting', () => { const creatorId = await getUserId(db) await insertFile(db, orgId, 'm-cloud-direct-blocked') await setTrafficQuota(db, orgId) - const share = await createShare(db, { matterId: 'm-cloud-direct-blocked', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ + matterId: 'm-cloud-direct-blocked', + orgId, + creatorId, + kind: 'direct', + }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) @@ -263,7 +273,12 @@ describe('public redirect cloud traffic reporting', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, 'm-cloud-landing-share') - const share = await createShare(db, { matterId: 'm-cloud-landing-share', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ + matterId: 'm-cloud-landing-share', + orgId, + creatorId, + kind: 'landing', + }) const ref = encodeChildRef(share.token, 'm-cloud-landing-share') const res = await app.request(`/api/shares/${share.token}/objects/${ref}?downloadUrl=1`, { redirect: 'manual' }) @@ -287,7 +302,12 @@ describe('public redirect cloud traffic reporting', () => { const creatorId = await getUserId(db) await insertFile(db, orgId, 'm-cloud-landing-blocked') await setTrafficQuota(db, orgId) - const share = await createShare(db, { matterId: 'm-cloud-landing-blocked', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ + matterId: 'm-cloud-landing-blocked', + orgId, + creatorId, + kind: 'landing', + }) const ref = encodeChildRef(share.token, 'm-cloud-landing-blocked') const res = await app.request(`/api/shares/${share.token}/objects/${ref}?downloadUrl=1`, { redirect: 'manual' }) @@ -322,7 +342,12 @@ describe('public redirect cloud traffic reporting', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, 'm-cloud-landing-audit-fail') - const share = await createShare(db, { matterId: 'm-cloud-landing-audit-fail', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ + matterId: 'm-cloud-landing-audit-fail', + orgId, + creatorId, + kind: 'landing', + }) const ref = encodeChildRef(share.token, 'm-cloud-landing-audit-fail') await db.run(sql`DROP TABLE activity_events`) diff --git a/server/routes/download-tasks.integration.test.ts b/server/http/download-tasks.integration.test.ts similarity index 97% rename from server/routes/download-tasks.integration.test.ts rename to server/http/download-tasks.integration.test.ts index 34abcf9e..10f7dfbe 100644 --- a/server/routes/download-tasks.integration.test.ts +++ b/server/http/download-tasks.integration.test.ts @@ -1,8 +1,8 @@ import type { Downloader, DownloadTask } from '@shared/types' import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3.js' import { remoteDownloadUsageReports } from '../db/schema' -import { S3Service } from '../services/s3.js' import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js' type DownloadTaskList = { items: DownloadTask[] } @@ -140,7 +140,7 @@ async function registerDownloaderThroughDeviceLogin( } describe('Download tasks API integration', () => { - it('registers a downloader through BetterAuth device login', async () => { + it('registers a downloader through BetterAuth device login [spec: download-tasks/register-downloader]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const created = await registerDownloaderThroughDeviceLogin(app, 'device-login-downloader') @@ -148,7 +148,7 @@ describe('Download tasks API integration', () => { expect(created.token).toBeTruthy() }) - it('rejects download tasks whose source URL targets an internal host', async () => { + it('rejects download tasks whose source URL targets an internal host [spec: download-tasks/ssrf-guard]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const user = await authedHeaders(app, 'ssrf-user@example.com') @@ -167,7 +167,7 @@ describe('Download tasks API integration', () => { } }) - it('rejects a magnet task whose URI is not a magnet link', async () => { + it('rejects a magnet task whose URI is not a magnet link [spec: download-tasks/magnet-validation]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const user = await authedHeaders(app, 'magnet-user@example.com') @@ -179,7 +179,7 @@ describe('Download tasks API integration', () => { expect(res.status).toBe(400) }) - it('deletes a downloader and returns unfinished tasks to the queue', async () => { + it('deletes a downloader and returns unfinished tasks to the queue [spec: download-tasks/delete-downloader-requeues]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const admin = await adminHeaders(app) @@ -222,7 +222,7 @@ describe('Download tasks API integration', () => { await expect(taskRes.json()).resolves.toMatchObject({ status: { state: 'queued', assignment: null } }) }) - it('does not assign new tasks to downloaders with stale heartbeats', async () => { + it('does not assign new tasks to downloaders with stale heartbeats [spec: download-tasks/stale-no-assign]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) await seedProLicense(db) // 2nd downloader requires downloaders_unlimited @@ -275,7 +275,7 @@ describe('Download tasks API integration', () => { }) }) - it('keeps tasks queued when matching downloaders are at capacity', async () => { + it('keeps tasks queued when matching downloaders are at capacity [spec: download-tasks/capacity-queue]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const admin = await adminHeaders(app) @@ -320,7 +320,7 @@ describe('Download tasks API integration', () => { }) }) - it('reports stale downloaders as offline in the admin list', async () => { + it('reports stale downloaders as offline in the admin list [spec: download-tasks/stale-offline]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const admin = await adminHeaders(app) @@ -350,7 +350,7 @@ describe('Download tasks API integration', () => { expect(listed?.status).toBe('offline') }) - it('reassigns unfinished tasks from stale downloaders on live heartbeat', async () => { + it('reassigns unfinished tasks from stale downloaders on live heartbeat [spec: download-tasks/reassign-on-heartbeat]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) await seedProLicense(db) // 2nd downloader requires downloaders_unlimited @@ -406,7 +406,7 @@ describe('Download tasks API integration', () => { }) }) - it('runs the remote download task upload flow through the standard object upload API', async () => { + it('runs the remote download task upload flow through the standard object upload API [spec: download-tasks/upload-flow]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) @@ -644,7 +644,7 @@ describe('Download tasks API integration', () => { expect(task.status.progress.upload.bytes).toBe(10 * 1024 * 1024) }) - it('accepts Cloud usage event ids that differ from local remote download idempotency keys', async () => { + it('accepts Cloud usage event ids that differ from local remote download idempotency keys [spec: download-tasks/cloud-usage-idempotency]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret', ZPAN_CLOUD_URL: 'https://cloud.example', @@ -720,7 +720,7 @@ describe('Download tasks API integration', () => { ]) }) - it('stores downloader runtime reports as snapshots while progress remains patchable', async () => { + it('stores downloader runtime reports as snapshots while progress remains patchable [spec: download-tasks/runtime-reports]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) @@ -868,7 +868,7 @@ describe('Download tasks API integration', () => { expect(seedingTask.status.runtime).not.toHaveProperty('etaSeconds') }) - it('returns storage failure details when multipart upload session creation fails', async () => { + it('returns storage failure details when multipart upload session creation fails [spec: download-tasks/upload-session-failure]', async () => { vi.mocked(S3Service.prototype.createMultipartUpload).mockRejectedValueOnce( new Error('bucket does not support multipart'), ) @@ -929,7 +929,7 @@ describe('Download tasks API integration', () => { }) }) - it('normalizes target folder paths when creating download tasks', async () => { + it('normalizes target folder paths when creating download tasks [spec: download-tasks/normalize-target]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) await registerDownloaderThroughDeviceLogin(app, 'target-folder-downloader') @@ -955,7 +955,7 @@ describe('Download tasks API integration', () => { expect(rows[0].target_folder).toBe('media/Movies') }) - it('returns storage failure details when multipart upload completion fails', async () => { + it('returns storage failure details when multipart upload completion fails [spec: download-tasks/upload-completion-failure]', async () => { vi.mocked(S3Service.prototype.completeMultipartUpload).mockRejectedValueOnce(new Error('InvalidPart: part missing')) const { app, db } = await createTestApp() await insertStorage(db) @@ -997,7 +997,7 @@ describe('Download tasks API integration', () => { }) }) - it('submits user task actions through downloader polling state', async () => { + it('submits user task actions through downloader polling state [spec: download-tasks/user-actions]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) @@ -1101,7 +1101,7 @@ describe('Download tasks API integration', () => { await expect(deleteRes.json()).resolves.toEqual({ id: createdTask.id, deleted: true }) }) - it('lets the assigned downloader recover interrupted tasks without resuming user-paused tasks', async () => { + it('lets the assigned downloader recover interrupted tasks without resuming user-paused tasks [spec: download-tasks/recover-interrupted]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) @@ -1193,7 +1193,7 @@ describe('Download tasks API integration', () => { await expect(pausedProgressRes.json()).resolves.toEqual({ error: 'Task is paused' }) }) - it('preserves the completed download checkpoint when retrying an upload failure', async () => { + it('preserves the completed download checkpoint when retrying an upload failure [spec: download-tasks/checkpoint-on-retry]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) @@ -1376,7 +1376,7 @@ describe('Download tasks API integration', () => { }) }) - it('uses transitional states for downloading task pause and cancel actions', async () => { + it('uses transitional states for downloading task pause and cancel actions [spec: download-tasks/transitional-actions]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) @@ -1469,7 +1469,7 @@ describe('Download tasks API integration', () => { await expect(canceledRes.json()).resolves.toMatchObject({ status: { state: 'canceled' } }) }) - it('rejects pause for billing-paused and uploading tasks', async () => { + it('rejects pause for billing-paused and uploading tasks [spec: download-tasks/reject-invalid-pause]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) @@ -1525,7 +1525,7 @@ describe('Download tasks API integration', () => { expect(uploadingPauseRes.status).toBe(409) }) - it('rejects invalid task actions', async () => { + it('rejects invalid task actions [spec: download-tasks/reject-invalid-action]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const user = await authedHeaders(app, 'invalid-download-actions-user@example.com') @@ -1553,7 +1553,7 @@ describe('Download tasks API integration', () => { }) }) - it('sorts and filters download tasks on the server', async () => { + it('sorts and filters download tasks on the server [spec: download-tasks/sort-filter]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await insertStorage(db) const user = await authedHeaders(app, 'download-sort-user@example.com') @@ -1610,7 +1610,7 @@ describe('Downloaders — free plan limit', () => { }) } - it('blocks the second downloader on the free plan with 402', async () => { + it('blocks the second downloader on the free plan with 402 [spec: download-tasks/free-limit]', async () => { const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) const admin = await adminHeaders(app) @@ -1623,7 +1623,7 @@ describe('Downloaders — free plan limit', () => { expect(body.limit).toBe(1) }) - it('allows additional downloaders with the downloaders_unlimited entitlement', async () => { + it('allows additional downloaders with the downloaders_unlimited entitlement [spec: download-tasks/unlimited-entitlement]', async () => { const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) await seedProLicense(db) const admin = await adminHeaders(app) diff --git a/server/routes/download-tasks.ts b/server/http/download-tasks.ts similarity index 89% rename from server/routes/download-tasks.ts rename to server/http/download-tasks.ts index a6fb8df4..6ffe414c 100644 --- a/server/routes/download-tasks.ts +++ b/server/http/download-tasks.ts @@ -12,12 +12,12 @@ import { requirePermission } from '../middleware/authz' import type { Env } from '../middleware/platform' import { createDownloadTask, - DownloadError, getDownloadTask, listDownloadTasks, performDownloadTaskAction, updateDownloadTask, -} from '../services/downloads' +} from '../usecases/downloads' +import { DownloadError } from '../usecases/ports' const errorSchema = z.object({ error: z.string() }) @@ -110,7 +110,7 @@ const downloadTasksRoute = new OpenAPIHono() const query = c.req.valid('query') as z.infer if (query.assignedTo === 'me') { if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401) - const result = await listDownloadTasks(c.get('platform'), { + const result = await listDownloadTasks(c.get('deps'), c.get('platform'), { downloaderId: principal.downloaderId, status: query.status, category: query.category, @@ -126,7 +126,7 @@ const downloadTasksRoute = new OpenAPIHono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) - const result = await listDownloadTasks(c.get('platform'), { + const result = await listDownloadTasks(c.get('deps'), c.get('platform'), { orgId, status: query.status, category: query.category, @@ -147,7 +147,7 @@ const downloadTasksRoute = new OpenAPIHono() c, async () => createDownloadTask( - c.get('platform'), + c.get('deps'), orgId, actorId, c.req.valid('json') as z.infer, @@ -159,14 +159,14 @@ const downloadTasksRoute = new OpenAPIHono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) const id = c.req.param('id') as string - return downloadTaskResponse(c, async () => getDownloadTask(c.get('platform'), orgId, id)) + return downloadTaskResponse(c, async () => getDownloadTask(c.get('deps'), orgId, id)) }) as never) .openapi(actionRoute, (async (c: OpenAPIContext) => { const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) const id = c.req.param('id') as string const { action } = c.req.valid('json') as z.infer - return downloadTaskResponse(c, async () => performDownloadTaskAction(c.get('platform'), orgId, id, action)) + return downloadTaskResponse(c, async () => performDownloadTaskAction(c.get('deps'), orgId, id, action)) }) as never) .openapi(updateRoute, (async (c: OpenAPIContext) => { const principal = c.get('principal') @@ -175,18 +175,26 @@ const downloadTasksRoute = new OpenAPIHono() return downloadTaskResponse( c, async () => - updateDownloadTask(c.get('platform'), id, c.req.valid('json') as z.infer, { - downloaderId: principal.downloaderId, - }), + updateDownloadTask( + c.get('deps'), + c.get('platform'), + id, + c.req.valid('json') as z.infer, + { downloaderId: principal.downloaderId }, + ), undefined, ) } const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) return downloadTaskResponse(c, async () => - updateDownloadTask(c.get('platform'), id, c.req.valid('json') as z.infer, { - orgId, - }), + updateDownloadTask( + c.get('deps'), + c.get('platform'), + id, + c.req.valid('json') as z.infer, + { orgId }, + ), ) }) as never) diff --git a/server/routes/downloaders.ts b/server/http/downloaders.ts similarity index 92% rename from server/routes/downloaders.ts rename to server/http/downloaders.ts index afc54d29..8fa2736b 100644 --- a/server/routes/downloaders.ts +++ b/server/http/downloaders.ts @@ -10,17 +10,18 @@ import { } from '@shared/schemas' import type { Context } from 'hono' import { FREE_DOWNLOADER_LIMIT } from '../../shared/constants' -import { hasFeature, loadBindingState } from '../licensing/has-feature' +import { hasFeature } from '../domain/licensing' import { requireAdmin, requireDownloader } from '../middleware/auth' import type { Env } from '../middleware/platform' import { createDownloader, - DownloadError, deleteDownloader, listDownloaders, recordDownloaderHeartbeat, updateDownloader, -} from '../services/downloads' +} from '../usecases/downloads' +import { loadBindingState } from '../usecases/licensing' +import { DownloadError } from '../usecases/ports' const errorSchema = z.object({ error: z.string() }) @@ -95,14 +96,14 @@ const heartbeatRoute = createRoute({ const downloadersRoute = new OpenAPIHono() .openapi(listRoute, (async (c: OpenAPIContext) => { - const items = await listDownloaders(c.get('platform')) + const items = await listDownloaders(c.get('deps')) return c.json({ items, total: items.length }) }) as never) .openapi(createRouteDoc, (async (c: OpenAPIContext) => { const userId = c.get('userId') if (!userId) return c.json({ error: 'Unauthorized' }, 401) - const platform = c.get('platform') - const [existing, state] = await Promise.all([listDownloaders(platform), loadBindingState(platform.db)]) + const deps = c.get('deps') + const [existing, state] = await Promise.all([listDownloaders(deps), loadBindingState(deps)]) if (!hasFeature('downloaders_unlimited', state) && existing.length >= FREE_DOWNLOADER_LIMIT) { return c.json( { @@ -116,7 +117,8 @@ const downloadersRoute = new OpenAPIHono() ) } const result = await createDownloader( - platform, + deps, + c.get('platform'), c.req.valid('json') as z.infer, userId, ) @@ -126,16 +128,16 @@ const downloadersRoute = new OpenAPIHono() const id = c.req.param('id') as string const input = c.req.valid('json') as z.infer if (input.remoteDownloadCreditBillingEnabled === true) { - const state = await loadBindingState(c.get('platform').db) + const state = await loadBindingState(c.get('deps')) if (!hasFeature('quota_store', state)) { return c.json({ error: 'feature_not_available', feature: 'quota_store' }, 402) } } - return downloadResponse(c, async () => updateDownloader(c.get('platform'), id, input)) + return downloadResponse(c, async () => updateDownloader(c.get('deps'), id, input)) }) as never) .openapi(deleteRoute, (async (c: OpenAPIContext) => { const id = c.req.param('id') as string - return downloadResponse(c, async () => deleteDownloader(c.get('platform'), id)) + return downloadResponse(c, async () => deleteDownloader(c.get('deps'), id)) }) as never) export const downloaderSelfRoute = new OpenAPIHono().openapi(heartbeatRoute, (async (c: OpenAPIContext) => { @@ -143,7 +145,7 @@ export const downloaderSelfRoute = new OpenAPIHono().openapi(heartbeatRoute if (principal?.kind !== 'downloader') return c.json({ error: 'Unauthorized' }, 401) return downloadResponse(c, async () => recordDownloaderHeartbeat( - c.get('platform'), + c.get('deps'), principal.downloaderId, c.req.valid('json') as z.infer, ), diff --git a/server/routes/email-config.integration.test.ts b/server/http/email-config.integration.test.ts similarity index 91% rename from server/routes/email-config.integration.test.ts rename to server/http/email-config.integration.test.ts index 6661f6c5..a041609d 100644 --- a/server/routes/email-config.integration.test.ts +++ b/server/http/email-config.integration.test.ts @@ -34,13 +34,13 @@ async function seedCloudflareConfig(db: Awaited } describe('Admin Email Config API — auth', () => { - it('GET returns 401 without auth', async () => { + it('GET returns 401 without auth [spec: email-config/auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/admin/email-config') expect(res.status).toBe(401) }) - it('GET returns 403 for non-admin user', async () => { + it('GET returns 403 for non-admin user [spec: email-config/admin-only]', async () => { const { app } = await createTestApp() await authedHeaders(app, 'admin@example.com') await authedHeaders(app, 'regular@example.com') @@ -76,7 +76,7 @@ describe('Admin Email Config API — auth', () => { }) describe('Admin Email Config API — GET', () => { - it('returns disabled empty state when no config exists', async () => { + it('returns disabled empty state when no config exists [spec: email-config/empty-state]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/email-config', { headers }) @@ -85,7 +85,7 @@ describe('Admin Email Config API — GET', () => { expect(body).toEqual({ enabled: false, provider: null }) }) - it('returns enabled with null provider when email is enabled but sender/provider are incomplete', async () => { + it('returns enabled with null provider when email is enabled but sender/provider are incomplete [spec: email-config/incomplete-provider]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await db.insert(schema.systemOptions).values([{ key: 'email_enabled', value: 'true' }]) @@ -98,7 +98,7 @@ describe('Admin Email Config API — GET', () => { }) }) - it('returns masked SMTP config after SMTP config is saved', async () => { + it('returns masked SMTP config after SMTP config is saved [spec: email-config/mask-smtp]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedSmtpConfig(db) @@ -120,7 +120,7 @@ describe('Admin Email Config API — GET', () => { expect(String(smtp.pass)).toMatch(/^\*+cret$/) }) - it('returns masked HTTP config after HTTP config is saved', async () => { + it('returns masked HTTP config after HTTP config is saved [spec: email-config/mask-http]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedHttpConfig(db) @@ -158,7 +158,7 @@ describe('Admin Email Config API — GET', () => { }) describe('Admin Email Config API — PUT', () => { - it('saves SMTP config and returns success', async () => { + it('saves SMTP config and returns success [spec: email-config/save-smtp]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -213,7 +213,7 @@ describe('Admin Email Config API — PUT', () => { expect(smtp.port).toBe(465) }) - it('saves HTTP config and returns success', async () => { + it('saves HTTP config and returns success [spec: email-config/save-http]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -261,7 +261,7 @@ describe('Admin Email Config API — PUT', () => { expect(http.url).toBe('https://api.sendgrid.com/v3/mail/send') }) - it('returns 400 for invalid provider value', async () => { + it('returns 400 for invalid provider value [spec: email-config/invalid-provider]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -273,7 +273,7 @@ describe('Admin Email Config API — PUT', () => { expect(res.status).toBe(400) }) - it('returns 400 for invalid from email', async () => { + it('returns 400 for invalid from email [spec: email-config/invalid-from]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -285,7 +285,7 @@ describe('Admin Email Config API — PUT', () => { expect(res.status).toBe(400) }) - it('updates existing config when PUT is called a second time', async () => { + it('updates existing config when PUT is called a second time [spec: email-config/update]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -319,7 +319,7 @@ describe('Admin Email Config API — PUT', () => { expect(smtp.port).toBe(587) }) - it('saves Cloudflare config and returns success', async () => { + it('saves Cloudflare config and returns success [spec: email-config/save-cloudflare]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -358,7 +358,7 @@ describe('Admin Email Config API — PUT', () => { }) }) - it('persists disabled state even when provider config exists', async () => { + it('persists disabled state even when provider config exists [spec: email-config/persist-disabled]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -385,7 +385,7 @@ describe('Admin Email Config API — POST /test', () => { vi.restoreAllMocks() }) - it('returns success when sendEmail succeeds', async () => { + it('returns success when sendEmail succeeds [spec: email-config/test-success]', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) @@ -403,7 +403,7 @@ describe('Admin Email Config API — POST /test', () => { expect(body.success).toBe(true) }) - it('returns 400 with error message when sendEmail fails', async () => { + it('returns 400 with error message when sendEmail fails [spec: email-config/test-failure]', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500, @@ -426,7 +426,7 @@ describe('Admin Email Config API — POST /test', () => { expect(typeof body.error).toBe('string') }) - it('returns 400 when no email config is set', async () => { + it('returns 400 when no email config is set [spec: email-config/test-no-config]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) diff --git a/server/routes/email-config.ts b/server/http/email-config.ts similarity index 83% rename from server/routes/email-config.ts rename to server/http/email-config.ts index 3f47b046..d21a8394 100644 --- a/server/routes/email-config.ts +++ b/server/http/email-config.ts @@ -1,11 +1,9 @@ import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { z } from 'zod' -import { systemOptions } from '../db/schema' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import type { Database } from '../platform/interface' -import { type EmailConfig, getEmailSettings, sendEmail } from '../services/email' +import type { EmailConfig, SystemOptionsRepo } from '../usecases/ports' const smtpConfigSchema = z.object({ enabled: z.boolean(), @@ -77,13 +75,9 @@ function maskConfig(config: EmailConfig): Record { } } -async function saveOptions(db: Database, entries: [string, string][]) { - const rows = entries.map(([key, value]) => ({ key, value, public: false })) - for (const row of rows) { - await db - .insert(systemOptions) - .values(row) - .onConflictDoUpdate({ target: systemOptions.key, set: { value: row.value, public: false } }) +async function saveOptions(repo: SystemOptionsRepo, entries: [string, string][]) { + for (const [key, value] of entries) { + await repo.set(key, value, false) } } @@ -91,14 +85,13 @@ const app = new Hono() .use(requireAdmin) .get('/', async (c) => { const platform = c.get('platform') - const settings = await getEmailSettings(platform) + const settings = await c.get('deps').email.getSettings(platform) return c.json({ enabled: settings.enabled, ...(settings.config ? maskConfig(settings.config) : { provider: null }), }) }) .put('/', zValidator('json', emailConfigSchema), async (c) => { - const db = c.get('platform').db const body = c.req.valid('json') const entries: [string, string][] = [ @@ -119,14 +112,14 @@ const app = new Hono() entries.push(['email_http_url', body.http.url], ['email_http_api_key', body.http.apiKey]) } - await saveOptions(db, entries) + await saveOptions(c.get('deps').systemOptions, entries) return c.json({ success: true }) }) .post('/test-messages', zValidator('json', testEmailSchema), async (c) => { const platform = c.get('platform') const { to } = c.req.valid('json') try { - await sendEmail(platform, { + await c.get('deps').email.send(platform, { to, subject: 'ZPan Test Email', html: '

Test Email

Your email configuration is working correctly.

', diff --git a/server/routes/events.integration.test.ts b/server/http/events.integration.test.ts similarity index 77% rename from server/routes/events.integration.test.ts rename to server/http/events.integration.test.ts index cb5f4cfc..ab8f58a1 100644 --- a/server/routes/events.integration.test.ts +++ b/server/http/events.integration.test.ts @@ -1,9 +1,8 @@ import { eq } from 'drizzle-orm' import { describe, expect, it, vi } from 'vitest' +import { createBackgroundJobRepo } from '../adapters/repos/background-job.js' +import { createOrgRepo } from '../adapters/repos/org.js' import * as authSchema from '../db/auth-schema.js' -import { createBackgroundJob } from '../services/background-jobs.js' -import * as notificationService from '../services/notification.js' -import { findPersonalOrg } from '../services/org.js' import { authedHeaders, createTestApp } from '../test/setup.js' const decoder = new TextDecoder() @@ -30,12 +29,12 @@ async function authedOrg(app: Awaited>) { .select({ id: authSchema.user.id }) .from(authSchema.user) .where(eq(authSchema.user.email, 'test@example.com')) - const orgId = await findPersonalOrg(app.db, user.id) + const orgId = await createOrgRepo(app.db).findPersonalOrg(user.id) return { headers, userId: user.id, orgId: orgId as string } } describe('GET /api/events', () => { - it('requires authentication', async () => { + it('requires authentication [spec: events/auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/events') @@ -43,11 +42,11 @@ describe('GET /api/events', () => { expect(res.status).toBe(401) }) - it('streams jobs and notifications events for an authed user', async () => { + it('streams jobs and notifications events for an authed user [spec: events/stream]', async () => { const testApp = await createTestApp() const { headers, userId, orgId } = await authedOrg(testApp) expect(orgId).toBeTruthy() - await createBackgroundJob(testApp.db, { orgId, userId, type: 'archive_compress' }) + await createBackgroundJobRepo(testApp.db).create({ orgId, userId, type: 'archive_compress' }) const res = await testApp.app.request('/api/events?downloadTasks=1', { headers }) expect(res.status).toBe(200) @@ -60,7 +59,7 @@ describe('GET /api/events', () => { expect(text).toContain('"unreadCount":0') }) - it('closes the stream when the request is aborted', async () => { + it('closes the stream when the request is aborted [spec: events/abort]', async () => { const testApp = await createTestApp() const { headers } = await authedOrg(testApp) @@ -77,10 +76,10 @@ describe('GET /api/events', () => { expect(next.done).toBe(true) }) - it('emits an error event when a domain query fails', async () => { + it('emits an error event when a domain query fails [spec: events/error-event]', async () => { const testApp = await createTestApp() const { headers } = await authedOrg(testApp) - vi.spyOn(notificationService, 'unreadCount').mockRejectedValueOnce(new Error('boom')) + vi.spyOn(testApp.deps.notifications, 'unreadCount').mockRejectedValueOnce(new Error('boom')) const res = await testApp.app.request('/api/events', { headers }) const text = await readSome(res, ['event: error']) diff --git a/server/routes/events.ts b/server/http/events.ts similarity index 90% rename from server/routes/events.ts rename to server/http/events.ts index a3ef70e6..2557814c 100644 --- a/server/routes/events.ts +++ b/server/http/events.ts @@ -2,9 +2,7 @@ import { Hono } from 'hono' import { z } from 'zod' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { listBackgroundJobs } from '../services/background-jobs' -import { listDownloadTasks } from '../services/downloads' -import { unreadCount } from '../services/notification' +import { listDownloadTasks } from '../usecases/downloads' const encoder = new TextEncoder() // How often the stream re-reads each subscribed domain to detect changes. Kept @@ -47,6 +45,7 @@ const eventsQuerySchema = z.object({ // a pure change-notifier with no pub/sub. export const events = new Hono().use(requireAuth).get('/', (c) => { const platform = c.get('platform') + const deps = c.get('deps') const orgId = c.get('orgId') const userId = c.get('userId') const query = eventsQuerySchema.parse(c.req.query()) @@ -72,8 +71,8 @@ export const events = new Hono().use(requireAuth).get('/', (c) => { if (orgId) { const [queued, running] = await Promise.all([ - listBackgroundJobs(platform.db, orgId, { status: 'queued', page: 1, pageSize: ACTIVE_JOB_SCAN_SIZE }), - listBackgroundJobs(platform.db, orgId, { status: 'running', page: 1, pageSize: ACTIVE_JOB_SCAN_SIZE }), + deps.backgroundJobs.list(orgId, { status: 'queued', page: 1, pageSize: ACTIVE_JOB_SCAN_SIZE }), + deps.backgroundJobs.list(orgId, { status: 'running', page: 1, pageSize: ACTIVE_JOB_SCAN_SIZE }), ]) const fingerprint = [...queued.items, ...running.items] .map((job) => `${job.id}:${job.status}:${job.updatedAt}:${job.progress.processedBytes}`) @@ -86,7 +85,7 @@ export const events = new Hono().use(requireAuth).get('/', (c) => { } if (userId) { - const count = await unreadCount(platform.db, userId) + const count = await deps.notifications.unreadCount(userId) const fingerprint = String(count) if (fingerprint !== unreadFingerprint) { unreadFingerprint = fingerprint @@ -96,7 +95,7 @@ export const events = new Hono().use(requireAuth).get('/', (c) => { } if (wantsDownloadTasks && orgId) { - const result = await listDownloadTasks(platform, { + const result = await listDownloadTasks(deps, platform, { orgId, status: query.dtStatus, category: query.dtCategory, diff --git a/server/routes/health.cf-test.ts b/server/http/health.cf-test.ts similarity index 100% rename from server/routes/health.cf-test.ts rename to server/http/health.cf-test.ts diff --git a/server/routes/health.integration.test.ts b/server/http/health.integration.test.ts similarity index 86% rename from server/routes/health.integration.test.ts rename to server/http/health.integration.test.ts index 877a5fb1..8a135e66 100644 --- a/server/routes/health.integration.test.ts +++ b/server/http/health.integration.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { createTestApp } from '../test/setup.js' describe('GET /api/health', () => { - it('returns ok', async () => { + it('returns ok [spec: health/ok]', async () => { const { app } = await createTestApp() const res = await app.request('/api/health') expect(res.status).toBe(200) diff --git a/server/routes/ihost-config.integration.test.ts b/server/http/ihost-config.integration.test.ts similarity index 96% rename from server/routes/ihost-config.integration.test.ts rename to server/http/ihost-config.integration.test.ts index 051338ba..d09d28f7 100644 --- a/server/routes/ihost-config.integration.test.ts +++ b/server/http/ihost-config.integration.test.ts @@ -131,7 +131,7 @@ describe('DELETE /api/ihost/config — unauth', () => { // ─── Role enforcement ────────────────────────────────────────────────────────── describe('/api/ihost/config — role enforcement', () => { - it('GET allows any org member', async () => { + it('GET allows any org member [spec: image-hosting-config/read-any-member]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `member-get-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -153,7 +153,7 @@ describe('/api/ihost/config — role enforcement', () => { expect(res.status).toBe(200) }) - it('PUT returns 403 for member role', async () => { + it('PUT returns 403 for member role [spec: image-hosting-config/write-requires-admin]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `member-put-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -230,7 +230,7 @@ describe('/api/ihost/config — role enforcement', () => { // ─── GET ─────────────────────────────────────────────────────────────────────── describe('GET /api/ihost/config', () => { - it('returns { enabled: false } when no config row exists', async () => { + it('returns { enabled: false } when no config row exists [spec: image-hosting-config/default-disabled]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `get-no-config-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -243,7 +243,7 @@ describe('GET /api/ihost/config', () => { expect(body.enabled).toBe(false) }) - it('returns domainStatus=none and null dnsInstructions when no customDomain set', async () => { + it('returns domainStatus=none and null dnsInstructions when no customDomain set [spec: image-hosting-config/no-domain]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `get-no-domain-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -259,7 +259,7 @@ describe('GET /api/ihost/config', () => { expect(body.dnsInstructions).toBeNull() }) - it('returns domainStatus=verified when domainVerifiedAt is set', async () => { + it('returns domainStatus=verified when domainVerifiedAt is set [spec: image-hosting-config/domain-verified]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `get-verified-status-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -279,7 +279,7 @@ describe('GET /api/ihost/config', () => { expect(body.domainVerifiedAt).toBeGreaterThan(0) }) - it('returns parsed refererAllowlist array', async () => { + it('returns parsed refererAllowlist array [spec: image-hosting-config/referer-allowlist]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `get-referer-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -296,7 +296,7 @@ describe('GET /api/ihost/config', () => { expect(body.refererAllowlist).toEqual(['https://blog.example.com', 'https://app.example.com']) }) - it('does NOT call CF when domain already verified', async () => { + it('does NOT call CF when domain already verified [spec: image-hosting-config/no-recheck-verified]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `get-no-cf-call-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -321,7 +321,7 @@ describe('GET /api/ihost/config', () => { vi.unstubAllGlobals() }) - it('lazily verifies domain when CF getStatus returns active', async () => { + it('lazily verifies domain when CF getStatus returns active [spec: image-hosting-config/lazy-verify]', async () => { const { app, db } = await createTestApp({ CF_API_TOKEN: 'tok', CF_ZONE_ID: 'zone', @@ -356,7 +356,7 @@ describe('GET /api/ihost/config', () => { vi.unstubAllGlobals() }) - it('stays pending when CF getStatus returns non-active', async () => { + it('stays pending when CF getStatus returns non-active [spec: image-hosting-config/stays-pending]', async () => { const { app, db } = await createTestApp({ CF_API_TOKEN: 'tok', CF_ZONE_ID: 'zone', @@ -391,7 +391,7 @@ describe('GET /api/ihost/config', () => { vi.unstubAllGlobals() }) - it('returns dnsInstructions with recordType=CNAME when CF is configured', async () => { + it('returns dnsInstructions with recordType=CNAME when CF is configured [spec: image-hosting-config/dns-cname]', async () => { const { app, db } = await createTestApp({ CF_API_TOKEN: 'tok', CF_ZONE_ID: 'zone', @@ -415,7 +415,7 @@ describe('GET /api/ihost/config', () => { expect(body.dnsInstructions?.target).toBe('ssl.zpan.io') }) - it('returns dnsInstructions with recordType=manual when CF is not configured', async () => { + it('returns dnsInstructions with recordType=manual when CF is not configured [spec: image-hosting-config/dns-manual]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `get-manual-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -454,7 +454,7 @@ describe('PUT /api/ihost/config', () => { vi.restoreAllMocks() }) - it('creates config row when enabled=true with no domain', async () => { + it('creates config row when enabled=true with no domain [spec: image-hosting-config/create]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `put-enabled-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -494,7 +494,7 @@ describe('PUT /api/ihost/config', () => { expect(body.domainStatus).toBe('pending') }) - it('calls CF register when CF is configured and stores cfHostnameId', async () => { + it('calls CF register when CF is configured and stores cfHostnameId [spec: image-hosting-config/cf-register]', async () => { const { app, db } = await createTestApp({ CF_API_TOKEN: 'tok', CF_ZONE_ID: 'zone', @@ -522,7 +522,7 @@ describe('PUT /api/ihost/config', () => { expect(rows[0].domainVerifiedAt).toBeNull() }) - it('changing customDomain calls CF delete then register', async () => { + it('changing customDomain calls CF delete then register [spec: image-hosting-config/domain-change]', async () => { const { app, db } = await createTestApp({ CF_API_TOKEN: 'tok', CF_ZONE_ID: 'zone', @@ -561,7 +561,7 @@ describe('PUT /api/ihost/config', () => { expect(rows[0].customDomain).toBe('new.example.com') }) - it('returns 409 when CF register returns 409 conflict', async () => { + it('returns 409 when CF register returns 409 conflict [spec: image-hosting-config/cf-conflict]', async () => { const { app, db } = await createTestApp({ CF_API_TOKEN: 'tok', CF_ZONE_ID: 'zone', @@ -603,7 +603,7 @@ describe('PUT /api/ihost/config', () => { expect(rows).toHaveLength(1) }) - it('returns 400 when enabled=false (must use DELETE to disable)', async () => { + it('returns 400 when enabled=false (must use DELETE to disable) [spec: image-hosting-config/disable-via-delete]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetHeaders(app, `put-disabled-${nanoid()}@example.com`) const orgId = await insertOrg(db) @@ -618,7 +618,7 @@ describe('PUT /api/ihost/config', () => { expect(res.status).toBe(400) }) - it('returns 400 when customDomain matches APP_HOST', async () => { + it('returns 400 when customDomain matches APP_HOST [spec: image-hosting-config/reject-app-host]', async () => { const { app, db } = await createTestApp({ APP_HOST: 'zpan.example.com' }) const { headers, userId } = await signUpAndGetHeaders(app, `put-apphost-${nanoid()}@example.com`) const orgId = await insertOrg(db) diff --git a/server/routes/ihost-config.ts b/server/http/ihost-config.ts similarity index 80% rename from server/routes/ihost-config.ts rename to server/http/ihost-config.ts index 2430039d..834b757f 100644 --- a/server/routes/ihost-config.ts +++ b/server/http/ihost-config.ts @@ -1,12 +1,10 @@ import { zValidator } from '@hono/zod-validator' -import { eq } from 'drizzle-orm' import { Hono } from 'hono' import { putIhostConfigSchema } from '../../shared/schemas' import type { IhostConfigResponse } from '../../shared/types' -import { imageHostingConfigs } from '../db/schema' import { requireAuth, requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { CfConflictError, createCfClient } from '../services/cf-custom-hostnames' +import { CfConflictError } from '../usecases/ports' function toUnixMs(d: Date | null | undefined): number | null { if (!d) return null @@ -61,32 +59,25 @@ function catchUniqueViolation(e: unknown): boolean { const app = new Hono() .use(requireAuth) .get('/', async (c) => { - const db = c.get('platform').db const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) const getEnv = c.get('platform').getEnv.bind(c.get('platform')) - const cfClient = createCfClient(getEnv) + const cfClient = c.get('deps').cfHostnames const isCfConfigured = !!getEnv('CF_API_TOKEN') const cnameTarget = getEnv('CF_CNAME_TARGET') ?? '' - const rows = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1) - - if (rows.length === 0) { + const row = await c.get('deps').imageHostingConfigs.getByOrg(orgId) + if (!row) { return c.json({ enabled: false }) } - const row = rows[0] - // Lazily refresh verification status when domain is unverified and CF is configured. if (row.customDomain && !row.domainVerifiedAt && row.cfHostnameId && isCfConfigured) { const status = await cfClient.getStatus(row.cfHostnameId) if (status.status === 'active') { const now = new Date() - await db - .update(imageHostingConfigs) - .set({ domainVerifiedAt: now, updatedAt: now }) - .where(eq(imageHostingConfigs.orgId, orgId)) + await c.get('deps').imageHostingConfigs.update(orgId, { domainVerifiedAt: now }) row.domainVerifiedAt = now } } @@ -94,13 +85,12 @@ const app = new Hono() return c.json(buildResponse(row, cnameTarget, isCfConfigured)) }) .put('/', requireTeamRole('owner'), zValidator('json', putIhostConfigSchema), async (c) => { - const db = c.get('platform').db const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) const body = c.req.valid('json') const getEnv = c.get('platform').getEnv.bind(c.get('platform')) - const cfClient = createCfClient(getEnv) + const cfClient = c.get('deps').cfHostnames const isCfConfigured = !!getEnv('CF_API_TOKEN') const cnameTarget = getEnv('CF_CNAME_TARGET') ?? '' const appHost = getEnv('APP_HOST') @@ -110,13 +100,13 @@ const app = new Hono() return c.json({ error: 'Custom domain cannot be the application default host' }, 400) } - const existing = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1) + const existing = await c.get('deps').imageHostingConfigs.getByOrg(orgId) const now = new Date() const newDomain = body.customDomain ?? null const newReferers = body.refererAllowlist !== undefined ? body.refererAllowlist : null - if (existing.length === 0) { + if (!existing) { // Insert new config row. let cfHostnameId: string | null = null if (newDomain && isCfConfigured) { @@ -132,14 +122,11 @@ const app = new Hono() } try { - await db.insert(imageHostingConfigs).values({ + await c.get('deps').imageHostingConfigs.create({ orgId, customDomain: newDomain, cfHostnameId, - domainVerifiedAt: null, refererAllowlist: newReferers ? JSON.stringify(newReferers) : null, - createdAt: now, - updatedAt: now, }) } catch (e) { if (catchUniqueViolation(e)) { @@ -164,7 +151,7 @@ const app = new Hono() } // Update existing config row. - const old = existing[0] + const old = existing const oldDomain = old.customDomain let cfHostnameId = old.cfHostnameId let domainVerifiedAt = old.domainVerifiedAt @@ -205,16 +192,12 @@ const app = new Hono() : old.refererAllowlist try { - await db - .update(imageHostingConfigs) - .set({ - customDomain: newDomain, - cfHostnameId, - domainVerifiedAt, - refererAllowlist: refererAllowlistValue, - updatedAt: now, - }) - .where(eq(imageHostingConfigs.orgId, orgId)) + await c.get('deps').imageHostingConfigs.update(orgId, { + customDomain: newDomain, + cfHostnameId, + domainVerifiedAt, + refererAllowlist: refererAllowlistValue, + }) } catch (e) { if (catchUniqueViolation(e)) { return c.json({ error: 'Domain already registered by another organization' }, 409) @@ -237,19 +220,15 @@ const app = new Hono() ) }) .delete('/', requireTeamRole('owner'), async (c) => { - const db = c.get('platform').db const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) - const existing = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1) - - if (existing.length === 0) { + const row = await c.get('deps').imageHostingConfigs.getByOrg(orgId) + if (!row) { return c.body(null, 204) } - const row = existing[0] - const getEnv = c.get('platform').getEnv.bind(c.get('platform')) - const cfClient = createCfClient(getEnv) + const cfClient = c.get('deps').cfHostnames // Best-effort CF cleanup — do not fail if CF call errors. if (row.cfHostnameId) { @@ -260,7 +239,7 @@ const app = new Hono() } } - await db.delete(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)) + await c.get('deps').imageHostingConfigs.delete(orgId) return c.body(null, 204) }) diff --git a/server/routes/ihost.cf-test.ts b/server/http/ihost.cf-test.ts similarity index 100% rename from server/routes/ihost.cf-test.ts rename to server/http/ihost.cf-test.ts diff --git a/server/routes/ihost.integration.test.ts b/server/http/ihost.integration.test.ts similarity index 95% rename from server/routes/ihost.integration.test.ts rename to server/http/ihost.integration.test.ts index ec12bedd..783f84d7 100644 --- a/server/routes/ihost.integration.test.ts +++ b/server/http/ihost.integration.test.ts @@ -1,9 +1,9 @@ import { sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { confirmImageHosting, deleteImageHosting } from '../services/image-hosting.js' -import { S3Service } from '../services/s3.js' +import { S3Service } from '../adapters/gateways/s3.js' import { authedHeaders, createTestApp } from '../test/setup.js' +import { confirmImageHosting, deleteImageHosting } from '../usecases/image-hosting.js' beforeEach(() => { vi.restoreAllMocks() @@ -127,7 +127,7 @@ async function insertImageHosting( // ─── POST /images — content type handling ──────────────────────────────────── describe('POST /api/ihost/images (content type handling)', () => { - it('returns 400 for application/json without base64 file field', async () => { + it('returns 400 for application/json without base64 file field [spec: image-hosting/json-missing-file]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -144,7 +144,7 @@ describe('POST /api/ihost/images (content type handling)', () => { expect(String(body.error)).toContain('file field') }) - it('returns 401 for application/json without any auth', async () => { + it('returns 401 for application/json without any auth [spec: image-hosting/json-auth]', async () => { const { app } = await createTestApp() const res = await app.request('/api/ihost/images', { @@ -155,7 +155,7 @@ describe('POST /api/ihost/images (content type handling)', () => { expect(res.status).toBe(401) }) - it('returns 415 for text/plain', async () => { + it('returns 415 for text/plain [spec: image-hosting/unsupported-content-type]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -170,7 +170,7 @@ describe('POST /api/ihost/images (content type handling)', () => { expect(res.status).toBe(415) }) - it('accepts base64 PNG via application/json (uPic upload)', async () => { + it('accepts base64 PNG via application/json (uPic upload) [spec: image-hosting/upic-upload]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -191,7 +191,7 @@ describe('POST /api/ihost/images (content type handling)', () => { expect(data.url).toBeDefined() }) - it('accepts explicit path in JSON base64 upload', async () => { + it('accepts explicit path in JSON base64 upload [spec: image-hosting/json-explicit-path]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -208,7 +208,7 @@ describe('POST /api/ihost/images (content type handling)', () => { expect(res.status).toBe(201) }) - it('returns 400 for invalid base64 in JSON', async () => { + it('returns 400 for invalid base64 in JSON [spec: image-hosting/invalid-base64]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -239,7 +239,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(res.status).toBe(401) }) - it('returns 401 for API key (presign requires session auth)', async () => { + it('returns 401 for API key (presign requires session auth) [spec: image-hosting/presign-session-only]', async () => { const { app, db, auth } = await createTestApp() await insertStorage(db) await authedHeaders(app) @@ -256,7 +256,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(res.status).toBe(401) }) - it('returns 403 when org has no image_hosting_configs row', async () => { + it('returns 403 when org has no image_hosting_configs row [spec: image-hosting/requires-config]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -271,7 +271,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(body.error).toContain('image hosting not enabled') }) - it('returns 503 when no storage is configured', async () => { + it('returns 503 when no storage is configured [spec: image-hosting/requires-storage]', async () => { // Do NOT insertStorage — selectStorage will throw → 503 const { app, db } = await createTestApp() const headers = await authedHeaders(app) @@ -288,7 +288,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(String(body.error)).toContain('storage') }) - it('returns 201 with draft row and presigned uploadUrl', async () => { + it('returns 201 with draft row and presigned uploadUrl [spec: image-hosting/presign]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -309,7 +309,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(String(body.storageKey)).toMatch(/^ih\//) }) - it('returns 400 for path with ..', async () => { + it('returns 400 for path with .. [spec: image-hosting/path-traversal]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -326,7 +326,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(body.error).toBe('invalid path') }) - it('returns 400 for path exceeding depth 5', async () => { + it('returns 400 for path exceeding depth 5 [spec: image-hosting/path-depth]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -344,7 +344,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(String(body.detail)).toContain('depth') }) - it('returns 400 for disallowed mime (image/svg+xml)', async () => { + it('returns 400 for disallowed mime (image/svg+xml) [spec: image-hosting/disallowed-svg]', async () => { // zValidator rejects disallowed mimes with 400 (Zod enum check) const { app, db } = await createTestApp() await insertStorage(db) @@ -376,7 +376,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(res.status).toBe(400) }) - it('returns 413 for size exceeding 20 MB', async () => { + it('returns 413 for size exceeding 20 MB [spec: image-hosting/size-limit]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -391,7 +391,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(res.status).toBe(413) }) - it('auto-suffixes path on collision', async () => { + it('auto-suffixes path on collision [spec: image-hosting/collision-suffix]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -516,7 +516,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { expect(String(body.detail)).toContain('invalid characters') }) - it('derives default path from blob filename (uses nanoid fallback)', async () => { + it('derives default path from blob filename (uses nanoid fallback) [spec: image-hosting/default-path]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -565,7 +565,7 @@ describe('POST /api/ihost/images/presign (JSON two-stage)', () => { // ─── POST multipart stream-proxy ───────────────────────────────────────────── describe('POST /api/ihost/images (multipart)', () => { - it('returns 201 with tool response on happy path, R2 put called', async () => { + it('returns 201 with tool response on happy path, R2 put called [spec: image-hosting/multipart-upload]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -592,7 +592,7 @@ describe('POST /api/ihost/images (multipart)', () => { expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1) }) - it('row status is active after multipart upload', async () => { + it('row status is active after multipart upload [spec: image-hosting/active-after-upload]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -652,7 +652,7 @@ describe('POST /api/ihost/images (multipart)', () => { expect(res.status).toBe(413) }) - it('returns 413 from Content-Length header before parsing body', async () => { + it('returns 413 from Content-Length header before parsing body [spec: image-hosting/content-length-guard]', async () => { // Sends explicit Content-Length > MAX_IMAGE_SIZE with a tiny body — triggers // the header-based early reject (before formData.get('file') is called) const { app, db } = await createTestApp() @@ -676,7 +676,7 @@ describe('POST /api/ihost/images (multipart)', () => { expect(res.status).toBe(413) }) - it('uses custom domain in url when configured and verified', async () => { + it('uses custom domain in url when configured and verified [spec: image-hosting/custom-domain]', async () => { const { app, db } = await createTestApp() await insertStorage(db) const headers = await authedHeaders(app) @@ -1291,15 +1291,15 @@ describe('POST /api/ihost/images — API key auth error paths', () => { // ─── Service unit tests (direct calls) ─────────────────────────────────────── -describe('image-hosting service — direct calls', () => { +describe('image-hosting usecase — direct calls', () => { it('deleteImageHosting returns null for nonexistent image', async () => { - const { db } = await createTestApp() - const result = await deleteImageHosting(db, 'no-such-id', 'no-such-org') + const { deps } = await createTestApp() + const result = await deleteImageHosting(deps, 'no-such-id', 'no-such-org', null) expect(result).toBeNull() }) it('confirmImageHosting with size=0 skips quota and confirms successfully', async () => { - const { db } = await createTestApp() + const { db, deps } = await createTestApp() const orgId = `org-sz0-${nanoid(6)}` const now = Date.now() await db.run( @@ -1313,7 +1313,7 @@ describe('image-hosting service — direct calls', () => { VALUES (${id}, ${orgId}, ${token}, 'sz0.png', 'st1', 'ih/x/y.png', 0, 'image/png', 'draft', 0, ${now}) `) - const result = await confirmImageHosting(db, id, orgId) + const result = await confirmImageHosting(deps, id, orgId) expect(result.row).toBeTruthy() expect(result.row?.status).toBe('active') }) diff --git a/server/routes/ihost.ts b/server/http/ihost.ts similarity index 76% rename from server/routes/ihost.ts rename to server/http/ihost.ts index fd987392..db37b7ad 100644 --- a/server/routes/ihost.ts +++ b/server/http/ihost.ts @@ -1,6 +1,6 @@ import { zValidator } from '@hono/zod-validator' -import { and, eq } from 'drizzle-orm' import { Hono } from 'hono' +import { nanoid } from 'nanoid' import { ALLOWED_IMAGE_MIMES, createIhostImageSchema, @@ -8,28 +8,23 @@ import { MAX_IMAGE_SIZE, patchIhostImageSchema, } from '../../shared/schemas' -import { imageHostings } from '../db/schema' +import { buildImageUrl, validatePath } from '../domain/image-hosting' import { mapDomainError } from '../lib/http-errors' +import { mimeToExt } from '../lib/mime-utils' import { requireAuth, requireTeamRole } from '../middleware/auth' import { requirePermission } from '../middleware/authz' import type { Env } from '../middleware/platform' -import { - buildImageUrl, - confirmImageHosting, - createImageHosting, - deleteImageHosting, - deriveDefaultPath, - getImageHosting, - getImageHostingConfig, - listImageHostings, - validatePath, -} from '../services/image-hosting' -import { S3Service } from '../services/s3' -import { getStorage, type Storage as S3Storage, selectStorage } from '../services/storage' -import { withStorageUsageReservation } from '../services/storage-usage' +import { confirmImageHosting, deleteImageHosting, finalizeImageHostingUpload } from '../usecases/image-hosting' +import type { StorageRecord as S3Storage } from '../usecases/ports' import { PRESIGN_TTL_SECS } from './share-utils' -const s3 = new S3Service() +// Derive a storage path from the upload's filename, falling back to a random +// name when the client sends an opaque blob. +function deriveDefaultPath(filename: string, mime: string): string { + if (!filename || filename === 'blob') return `image-${nanoid(8)}.${mimeToExt(mime)}` + // Strip path separators from the filename for safety + return filename.replace(/[/\\]/g, '_') +} // Detect image MIME type from the first few bytes (magic numbers) function detectMimeFromBytes(bytes: Uint8Array): string | null { @@ -67,18 +62,17 @@ function detectMimeFromBytes(bytes: Uint8Array): string | null { const app = new Hono() .post('/images', requirePermission('ihost', 'upload'), async (c) => { - const db = c.get('platform').db - const contentType = c.req.header('Content-Type') ?? '' - const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) - const config = await getImageHostingConfig(db, orgId) + const contentType = c.req.header('Content-Type') ?? '' + + const config = await c.get('deps').imageHostingConfigs.getByOrg(orgId) if (!config) return c.json({ error: 'image hosting not enabled for this organization' }, 403) let storage: S3Storage try { - storage = await selectStorage(db, 'private') + storage = await c.get('deps').storages.select('private') } catch { return c.json({ error: 'No storage configured' }, 503) } @@ -163,34 +157,13 @@ const app = new Hono() if (pathErr) return c.json(pathErr, 400) try { - const row = await withStorageUsageReservation( - db, - { orgId, storageId: storage.id, bytes: fileBytes.byteLength }, - async (ctx) => { - const row = await createImageHosting(db, { - orgId, - path: requestedPath, - mime: mime as (typeof ALLOWED_IMAGE_MIMES)[number], - size: fileBytes.byteLength, - storageId: storage.id, - status: 'draft', - }) - - ctx.onRollback(async () => { - await db.delete(imageHostings).where(and(eq(imageHostings.id, row.id), eq(imageHostings.orgId, orgId))) - await s3.deleteObject(storage, row.storageKey) - }) - - await s3.putObject(storage, row.storageKey, fileBytes, mime) - - await db - .update(imageHostings) - .set({ status: 'active' }) - .where(and(eq(imageHostings.id, row.id), eq(imageHostings.orgId, orgId))) - - return row - }, - ) + const row = await finalizeImageHostingUpload(c.get('deps'), { + orgId, + storage, + path: requestedPath, + mime: mime as (typeof ALLOWED_IMAGE_MIMES)[number], + bytes: fileBytes, + }) const origin = new URL(c.req.url).origin const tokenUrl = `${origin}/r/${row.token}` @@ -228,8 +201,7 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db - const config = await getImageHostingConfig(db, orgId) + const config = await c.get('deps').imageHostingConfigs.getByOrg(orgId) if (!config) return c.json({ error: 'image hosting not enabled for this organization' }, 403) const { path: requestedPath, mime, size } = c.req.valid('json') @@ -243,12 +215,12 @@ const app = new Hono() let storage: S3Storage try { - storage = await selectStorage(db, 'private') + storage = await c.get('deps').storages.select('private') } catch { return c.json({ error: 'No storage configured' }, 503) } - const row = await createImageHosting(db, { + const row = await c.get('deps').imageHosting.create({ orgId, path: requestedPath, mime, @@ -257,7 +229,7 @@ const app = new Hono() status: 'draft', }) - const uploadUrl = await s3.presignUpload(storage, row.storageKey, mime, PRESIGN_TTL_SECS) + const uploadUrl = await c.get('deps').s3.presignUpload(storage, row.storageKey, mime, PRESIGN_TTL_SECS) return c.json( { @@ -278,12 +250,11 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db - const config = await getImageHostingConfig(db, orgId) + const config = await c.get('deps').imageHostingConfigs.getByOrg(orgId) if (!config) return c.json({ error: 'image hosting not enabled for this organization' }, 403) const { pathPrefix, cursor, limit } = c.req.valid('query') - const result = await listImageHostings(db, orgId, { pathPrefix, cursor, limit }) + const result = await c.get('deps').imageHosting.list(orgId, { pathPrefix, cursor, limit }) return c.json(result) }) @@ -291,11 +262,10 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db - const config = await getImageHostingConfig(db, orgId) + const config = await c.get('deps').imageHostingConfigs.getByOrg(orgId) if (!config) return c.json({ error: 'image hosting not enabled for this organization' }, 403) - const row = await getImageHosting(db, c.req.param('id'), orgId) + const row = await c.get('deps').imageHosting.get(c.req.param('id'), orgId) if (!row) return c.json({ error: 'Not found' }, 404) return c.json(row) }) @@ -309,12 +279,11 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db - const config = await getImageHostingConfig(db, orgId) + const config = await c.get('deps').imageHostingConfigs.getByOrg(orgId) if (!config) return c.json({ error: 'image hosting not enabled for this organization' }, 403) // action === 'confirm' is the only value the discriminated union allows - const { row, quotaExceeded } = await confirmImageHosting(db, c.req.param('id'), orgId) + const { row, quotaExceeded } = await confirmImageHosting(c.get('deps'), c.req.param('id'), orgId) if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422) if (!row) return c.json({ error: 'Not found or not in draft status' }, 404) return c.json(row) @@ -325,23 +294,15 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db - const config = await getImageHostingConfig(db, orgId) + const config = await c.get('deps').imageHostingConfigs.getByOrg(orgId) if (!config) return c.json({ error: 'image hosting not enabled for this organization' }, 403) - const existing = await getImageHosting(db, c.req.param('id'), orgId) + const existing = await c.get('deps').imageHosting.get(c.req.param('id'), orgId) if (!existing) return c.json({ error: 'Not found' }, 404) - const storage = await getStorage(db, existing.storageId) - if (storage) { - try { - await s3.deleteObject(storage, existing.storageKey) - } catch { - // Best-effort S3 delete — proceed with DB cleanup regardless - } - } - - await deleteImageHosting(db, existing.id, orgId) + const storage = await c.get('deps').storages.get(existing.storageId) + const deleted = await deleteImageHosting(c.get('deps'), existing.id, orgId, storage) + if (!deleted) return c.json({ error: 'Not found' }, 404) return new Response(null, { status: 204 }) }) diff --git a/server/routes/internal.test.ts b/server/http/internal.test.ts similarity index 88% rename from server/routes/internal.test.ts rename to server/http/internal.test.ts index 197fd92c..ff809ae1 100644 --- a/server/routes/internal.test.ts +++ b/server/http/internal.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { reportInstanceTelemetry } from '../services/instance-telemetry' import { createTestApp } from '../test/setup.js' +import { reportInstanceTelemetry } from '../usecases/instance-telemetry' -vi.mock('../services/instance-telemetry', () => ({ +vi.mock('../usecases/instance-telemetry', () => ({ INSTANCE_TELEMETRY_CRON: '0 */12 * * *', reportInstanceTelemetry: vi.fn(), })) @@ -38,7 +38,7 @@ describe('POST /api/internal/instance-telemetry/report', () => { }) it('reports telemetry with the configured internal token', async () => { - const { app, db } = await createTestApp({ + const { app, deps } = await createTestApp({ ZPAN_INTERNAL_API_TOKEN: 'test-token', }) @@ -49,8 +49,7 @@ describe('POST /api/internal/instance-telemetry/report', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ reported: true }) - expect(reportInstanceTelemetry).toHaveBeenCalledWith({ - db, + expect(reportInstanceTelemetry).toHaveBeenCalledWith(deps, { config: { allowIp: true, }, diff --git a/server/routes/internal.ts b/server/http/internal.ts similarity index 93% rename from server/routes/internal.ts rename to server/http/internal.ts index cf1f7d65..1fd320da 100644 --- a/server/routes/internal.ts +++ b/server/http/internal.ts @@ -3,7 +3,7 @@ import { Hono } from 'hono' import { constantTimeEqual } from '../lib/constant-time' import type { Env } from '../middleware/platform' import { getDeployPlatform } from '../runtime-platform' -import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../services/instance-telemetry' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../usecases/instance-telemetry' const INTERNAL_API_TOKEN_ENV = 'ZPAN_INTERNAL_API_TOKEN' @@ -35,8 +35,7 @@ internal.post('/instance-telemetry/report', async (c) => { nodeVersion: process.version, } - const result = await reportInstanceTelemetry({ - db: platform.db, + const result = await reportInstanceTelemetry(c.get('deps'), { config: { allowIp: envAllowsIp(platform.getEnv('ZPAN_TELEMETRY_ALLOW_IP')), }, diff --git a/server/routes/invite-codes.integration.test.ts b/server/http/invite-codes.integration.test.ts similarity index 87% rename from server/routes/invite-codes.integration.test.ts rename to server/http/invite-codes.integration.test.ts index 7ebde413..49ba833e 100644 --- a/server/routes/invite-codes.integration.test.ts +++ b/server/http/invite-codes.integration.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from 'vitest' -import { generateInviteCodes, redeemInviteCode } from '../services/invite.js' +import { createInviteRepo } from '../adapters/repos/invite.js' import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' // ─── Admin routes ───────────────────────────────────────────────────────────── describe('Admin Invite Codes API — auth guards', () => { - it('GET / returns 401 without auth', async () => { + it('GET / returns 401 without auth [spec: invite-codes/admin-auth]', async () => { const { app } = await createTestApp() const res = await app.request('/api/admin/invite-codes') expect(res.status).toBe(401) }) - it('GET / returns 403 for a non-admin user', async () => { + it('GET / returns 403 for a non-admin user [spec: invite-codes/admin-only]', async () => { const { app } = await createTestApp() await adminHeaders(app) // first user becomes admin const headers = await authedHeaders(app, 'regular@example.com') @@ -46,7 +46,7 @@ describe('Admin Invite Codes API — GET /', () => { expect(body).toEqual({ items: [], total: 0 }) }) - it('returns created codes with correct total', async () => { + it('returns created codes with correct total [spec: invite-codes/list]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -82,7 +82,7 @@ describe('Admin Invite Codes API — GET /', () => { }) describe('Admin Invite Codes API — POST /', () => { - it('creates the requested number of codes and returns 201', async () => { + it('creates the requested number of codes and returns 201 [spec: invite-codes/generate]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { @@ -95,7 +95,7 @@ describe('Admin Invite Codes API — POST /', () => { expect(body.codes).toHaveLength(4) }) - it('creates codes with an expiry when expiresInDays is provided', async () => { + it('creates codes with an expiry when expiresInDays is provided [spec: invite-codes/generate-expiry]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { @@ -119,7 +119,7 @@ describe('Admin Invite Codes API — POST /', () => { expect(res.status).toBe(400) }) - it('returns 400 when count exceeds maximum of 100', async () => { + it('returns 400 when count exceeds maximum of 100 [spec: invite-codes/generate-limit]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/invite-codes', { @@ -143,10 +143,10 @@ describe('Admin Invite Codes API — POST /', () => { }) describe('Admin Invite Codes API — DELETE /:id', () => { - it('deletes an unused code and returns deleted:true', async () => { + it('deletes an unused code and returns deleted:true [spec: invite-codes/delete]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) - const [row] = await generateInviteCodes(db, 'admin-user', 1) + const [row] = await createInviteRepo(db).generate('admin-user', 1) const res = await app.request(`/api/admin/invite-codes/${row.id}`, { method: 'DELETE', @@ -168,11 +168,11 @@ describe('Admin Invite Codes API — DELETE /:id', () => { expect(res.status).toBe(404) }) - it('returns 400 when trying to delete an already-used code', async () => { + it('returns 400 when trying to delete an already-used code [spec: invite-codes/delete-used]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) - const [row] = await generateInviteCodes(db, 'admin-user', 1) - await redeemInviteCode(db, row.code, 'user-123') + const [row] = await createInviteRepo(db).generate('admin-user', 1) + await createInviteRepo(db).redeem(row.code, 'user-123') const res = await app.request(`/api/admin/invite-codes/${row.id}`, { method: 'DELETE', @@ -185,9 +185,9 @@ describe('Admin Invite Codes API — DELETE /:id', () => { // ─── Public routes ──────────────────────────────────────────────────────────── describe('Public Invite Codes API — POST /validate', () => { - it('returns valid:true for a valid unused code', async () => { + it('returns valid:true for a valid unused code [spec: invite-codes/validate]', async () => { const { app, db } = await createTestApp() - const [row] = await generateInviteCodes(db, 'admin-1', 1) + const [row] = await createInviteRepo(db).generate('admin-1', 1) const res = await app.request('/api/invite-codes/validations', { method: 'POST', @@ -214,8 +214,8 @@ describe('Public Invite Codes API — POST /validate', () => { it('returns valid:false for a used code', async () => { const { app, db } = await createTestApp() - const [row] = await generateInviteCodes(db, 'admin-1', 1) - await redeemInviteCode(db, row.code, 'user-99') + const [row] = await createInviteRepo(db).generate('admin-1', 1) + await createInviteRepo(db).redeem(row.code, 'user-99') const res = await app.request('/api/invite-codes/validations', { method: 'POST', @@ -230,7 +230,7 @@ describe('Public Invite Codes API — POST /validate', () => { it('returns valid:false for an expired code', async () => { const { app, db } = await createTestApp() const past = new Date(Date.now() - 1000) - const [row] = await generateInviteCodes(db, 'admin-1', 1, past) + const [row] = await createInviteRepo(db).generate('admin-1', 1, past) const res = await app.request('/api/invite-codes/validations', { method: 'POST', @@ -294,7 +294,7 @@ describe('Public Invite Codes API — POST /validate', () => { it('is accessible without authentication', async () => { const { app, db } = await createTestApp() - const [row] = await generateInviteCodes(db, 'admin-1', 1) + const [row] = await createInviteRepo(db).generate('admin-1', 1) // No auth headers — should still work const res = await app.request('/api/invite-codes/validations', { diff --git a/server/routes/invite-codes.ts b/server/http/invite-codes.ts similarity index 77% rename from server/routes/invite-codes.ts rename to server/http/invite-codes.ts index 114e0bd5..4de9df3b 100644 --- a/server/routes/invite-codes.ts +++ b/server/http/invite-codes.ts @@ -3,8 +3,6 @@ import { Hono } from 'hono' import { z } from 'zod' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { deleteInviteCode, generateInviteCodes, listInviteCodes, validateInviteCode } from '../services/invite' const generateSchema = z.object({ count: z.number().int().min(1).max(100), @@ -26,20 +24,18 @@ const paginationSchema = z.object({ export const adminInviteCodes = new Hono() .use(requireAdmin) .get('/', zValidator('query', paginationSchema), async (c) => { - const db = c.get('platform').db const { page, pageSize } = c.req.valid('query') - const result = await listInviteCodes(db, page, pageSize) + const result = await c.get('deps').invites.list(page, pageSize) return c.json(result) }) .post('/', zValidator('json', generateSchema), async (c) => { - const db = c.get('platform').db const userId = c.get('userId') if (!userId) return c.json({ error: 'Unauthorized' }, 401) const orgId = c.get('orgId')! const { count, expiresInDays } = c.req.valid('json') const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 86400000) : undefined - const codes = await generateInviteCodes(db, userId, count, expiresAt) - await recordActivity(db, { + const codes = await c.get('deps').invites.generate(userId, count, expiresAt) + await c.get('deps').activity.record({ orgId, userId, action: 'invite_code_generate', @@ -50,14 +46,13 @@ export const adminInviteCodes = new Hono() return c.json({ codes }, 201) }) .delete('/:id', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! const id = c.req.param('id') - const result = await deleteInviteCode(db, id) + const result = await c.get('deps').invites.delete(id) if (result === 'not_found') return c.json({ error: 'Invite code not found' }, 404) if (result === 'already_used') return c.json({ error: 'Cannot delete a used invite code' }, 400) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'invite_code_delete', @@ -69,8 +64,7 @@ export const adminInviteCodes = new Hono() }) export const publicInviteCodes = new Hono().post('/validations', zValidator('json', validateSchema), async (c) => { - const db = c.get('platform').db const { code } = c.req.valid('json') - const result = await validateInviteCode(db, code) + const result = await c.get('deps').invites.validate(code) return c.json(result) }) diff --git a/server/routes/licensing-admin.integration.test.ts b/server/http/licensing-admin.integration.test.ts similarity index 83% rename from server/routes/licensing-admin.integration.test.ts rename to server/http/licensing-admin.integration.test.ts index 3b7f3bd2..8e470c6d 100644 --- a/server/routes/licensing-admin.integration.test.ts +++ b/server/http/licensing-admin.integration.test.ts @@ -1,8 +1,8 @@ import { generateKeys, sign } from 'paseto-ts/v4' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getOrCreateInstanceId } from '../licensing/instance-id.js' -import { createLicenseBinding, loadLicenseState } from '../licensing/license-state.js' -import { PUBLIC_KEYS } from '../licensing/public-keys.js' +import { createInstanceRepo } from '../adapters/repos/instance.js' +import { createLicenseBindingRepo } from '../adapters/repos/license-binding.js' +import { PUBLIC_KEYS } from '../domain/license-keys.js' import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' function makeCloudResponse(body: unknown, status = 200): Response { @@ -42,7 +42,7 @@ function signCert(instanceId: string, secret: string = TEST_SECRET): string { async function seedBinding(db: Awaited>['db'], instanceId = 'inst-1') { const now = nowSec() const cert = signCert(instanceId) - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'bind-1', instanceId, cloudAccountId: 'acct-1', @@ -55,7 +55,7 @@ async function seedBinding(db: Awaited>['db'], } describe('Licensing Admin API — auth guards', () => { - it('POST /api/licensing/pair returns 401 without auth', async () => { + it('POST /api/licensing/pair returns 401 without auth [spec: licensing-admin/auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/licensing/pair', { method: 'POST' }) expect(res.status).toBe(401) @@ -79,7 +79,7 @@ describe('Licensing Admin API — auth guards', () => { expect(res.status).toBe(401) }) - it('POST /api/licensing/pair returns 403 for non-admin', async () => { + it('POST /api/licensing/pair returns 403 for non-admin [spec: licensing-admin/admin-only]', async () => { const { app } = await createTestApp() await authedHeaders(app, 'admin@example.com') await authedHeaders(app, 'regular@example.com') @@ -108,7 +108,7 @@ describe('POST /api/licensing/pair', () => { for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key) }) - it('calls cloud and returns pairing info', async () => { + it('calls cloud and returns pairing info [spec: licensing-admin/pair-initiate]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -148,7 +148,7 @@ describe('GET /api/licensing/pair/:code/poll', () => { for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key) }) - it('returns pending status when cloud returns pending', async () => { + it('returns pending status when cloud returns pending [spec: licensing-admin/poll-pending]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -161,10 +161,10 @@ describe('GET /api/licensing/pair/:code/poll', () => { expect(body.status).toBe('pending') }) - it('stores binding on approved and returns approved status', async () => { + it('stores binding on approved and returns approved status [spec: licensing-admin/poll-approved]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) - const instanceId = await getOrCreateInstanceId(db) + const instanceId = await createInstanceRepo(db).getOrCreateInstanceId() vi.mocked(fetch).mockResolvedValueOnce( makeCloudResponse({ @@ -184,15 +184,15 @@ describe('GET /api/licensing/pair/:code/poll', () => { expect(body.cloud_store_id).toBe('store-1') // Check that binding was persisted - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBe('rt-secret') expect(state.cloudStoreId).toBe('store-1') }) - it('stores the pairing certificate when approved', async () => { + it('stores the pairing certificate when approved [spec: licensing-admin/store-cert]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) - const instanceId = await getOrCreateInstanceId(db) + const instanceId = await createInstanceRepo(db).getOrCreateInstanceId() const certificate = signCert(instanceId) vi.mocked(fetch) @@ -211,7 +211,7 @@ describe('GET /api/licensing/pair/:code/poll', () => { const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers }) expect(res.status).toBe(200) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBe('pair-rt') expect(state.cachedCert).toBe(certificate) // Poll + confirm (PATCH /licenses/:id { status: 'confirmed' }). @@ -222,7 +222,7 @@ describe('GET /api/licensing/pair/:code/poll', () => { expect(JSON.parse(confirmInit.body as string)).toEqual({ status: 'confirmed' }) }) - it('rejects approved responses with an invalid certificate', async () => { + it('rejects approved responses with an invalid certificate [spec: licensing-admin/reject-invalid-cert]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -237,11 +237,11 @@ describe('GET /api/licensing/pair/:code/poll', () => { const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers }) expect(res.status).toBe(502) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBeNull() }) - it('rejects approved responses when certificate is missing', async () => { + it('rejects approved responses when certificate is missing [spec: licensing-admin/reject-missing-cert]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -255,14 +255,14 @@ describe('GET /api/licensing/pair/:code/poll', () => { const res = await app.request('/api/licensing/pair/CODE-1/poll', { headers }) expect(res.status).toBe(502) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBeNull() }) it('rejects approved responses when binding metadata is missing', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) - const instanceId = await getOrCreateInstanceId(db) + const instanceId = await createInstanceRepo(db).getOrCreateInstanceId() vi.mocked(fetch).mockResolvedValueOnce( makeCloudResponse({ @@ -279,16 +279,16 @@ describe('GET /api/licensing/pair/:code/poll', () => { error: 'invalid_certificate', reason: 'incomplete_response', }) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.status).toBe('disconnected') expect(state.refreshToken).toBeNull() expect(state.cachedCert).toBeNull() }) - it('reports an untrusted signing key and rolls back the orphaned cloud binding', async () => { + it('reports an untrusted signing key and rolls back the orphaned cloud binding [spec: licensing-admin/untrusted-key-rollback]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) - const instanceId = await getOrCreateInstanceId(db) + const instanceId = await createInstanceRepo(db).getOrCreateInstanceId() // Sign with a key ZPan does not trust — simulates a rotated/mismatched cloud // signing key (the real-world "lost private key" scenario). @@ -315,7 +315,7 @@ describe('GET /api/licensing/pair/:code/poll', () => { reason: 'signature', }) // ZPan stored nothing; the cloud binding was released. - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBeNull() const unbindCall = vi.mocked(fetch).mock.calls.at(-1) expect(String(unbindCall?.[0])).toContain('cb-1') @@ -336,7 +336,7 @@ describe('POST /api/licensing/refresh', () => { for (const key of originalKeys.splice(0)) PUBLIC_KEYS.push(key) }) - it('returns success when binding exists and cloud responds OK', async () => { + it('returns success when binding exists and cloud responds OK [spec: licensing-admin/refresh]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -358,7 +358,7 @@ describe('POST /api/licensing/refresh', () => { expect(body.success).toBe(true) }) - it('returns success:true with null last_refresh_at when no binding exists', async () => { + it('returns success:true with null last_refresh_at when no binding exists [spec: licensing-admin/refresh-unbound]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -381,7 +381,7 @@ describe('DELETE /api/licensing/binding', () => { vi.unstubAllGlobals() }) - it('unbinds from Cloud, deletes binding row, and returns deleted: true', async () => { + it('unbinds from Cloud, deletes binding row, and returns deleted: true [spec: licensing-admin/unbind]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -399,11 +399,11 @@ describe('DELETE /api/licensing/binding', () => { expect(new Headers(init.headers).get('Authorization')).toBe('Bearer old-token') // Confirm binding is gone - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBeNull() }) - it('clears the local binding when Cloud unbind fails', async () => { + it('clears the local binding when Cloud unbind fails [spec: licensing-admin/unbind-cloud-fail]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -417,11 +417,11 @@ describe('DELETE /api/licensing/binding', () => { expect(body.deleted).toBe(true) expect(body.cloud_unbind_error).toContain('Cloud unbind failed') - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBeNull() }) - it('returns deleted: true even when no binding exists', async () => { + it('returns deleted: true even when no binding exists [spec: licensing-admin/unbind-idempotent]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) diff --git a/server/routes/licensing-admin.ts b/server/http/licensing-admin.ts similarity index 70% rename from server/routes/licensing-admin.ts rename to server/http/licensing-admin.ts index 3ef9f0c5..fd60af0b 100644 --- a/server/routes/licensing-admin.ts +++ b/server/http/licensing-admin.ts @@ -1,41 +1,33 @@ import { Hono } from 'hono' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' -import { invalidateEntitlementCache } from '../licensing/entitlement' -import { getOrCreateInstanceId } from '../licensing/instance-id' -import { buildCloudInstanceInfo, runtimeInfo } from '../licensing/instance-info' -import { clearLicenseBinding, createLicenseBinding, loadLicenseState } from '../licensing/license-state' -import { performRefresh } from '../licensing/refresh' -import { normalizeHost, verifyCertificateResult } from '../licensing/verify' +import { originFromRequestUrl } from '../domain/site-public-origin' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { - confirmCloudLicense, - createPairing, - type PairingPollResponse, - pollPairing, - unbindCloudLicense, -} from '../services/licensing-cloud' -import { getSitePublicOrigin, originFromRequestUrl } from '../services/site-public-origin' +import { buildCloudInstanceInfo, runtimeInfo } from '../usecases/instance-info' +import { normalizeHost, verifyCertificateResult } from '../usecases/license-certificate' +import { invalidateEntitlementCache } from '../usecases/license-entitlement' +import { performRefresh } from '../usecases/license-refresh' +import type { PairingPollResponse } from '../usecases/ports' +import { getSitePublicOrigin } from '../usecases/site-public-origin' function getCloudBaseUrl(c: { get(key: 'platform'): { getEnv(k: string): string | undefined } }): string { return c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT } async function getInstanceOrigin(c: { - get(key: 'platform'): { db: import('../platform/interface').Database } + get(key: 'deps'): Env['Variables']['deps'] req: { url: string; header(name: string): string | undefined } }): Promise { - const configured = await getSitePublicOrigin(c.get('platform').db) + const configured = await getSitePublicOrigin(c.get('deps')) if (configured) return configured return originFromRequestUrl(c.req.url) ?? new URL(c.req.url).origin } async function getRequestHost(c: { - get(key: 'platform'): { db: import('../platform/interface').Database } + get(key: 'deps'): Env['Variables']['deps'] req: { url: string; header(name: string): string | undefined } }): Promise { - const configured = await getSitePublicOrigin(c.get('platform').db) + const configured = await getSitePublicOrigin(c.get('deps')) if (configured) return new URL(configured).host const forwardedHost = c.req.header('x-forwarded-host') ?? c.req.header('host') return normalizeHost(forwardedHost) ?? new URL(c.req.url).host @@ -45,10 +37,14 @@ async function getRequestHost(c: { // message (surfaced for diagnostics) or null. Leaving the cloud binding orphaned is // the safe direction — ZPan stays unbound either way — so a failure here does not // change the user-facing outcome. -async function rollbackCloudBinding(baseUrl: string, result: PairingPollResponse): Promise { +async function rollbackCloudBinding( + licensingCloud: Env['Variables']['deps']['licensingCloud'], + baseUrl: string, + result: PairingPollResponse, +): Promise { if (!result.refreshToken || !result.binding?.id) return null try { - await unbindCloudLicense(baseUrl, result.binding.id, result.refreshToken) + await licensingCloud.unbindCloudLicense(baseUrl, result.binding.id, result.refreshToken) return null } catch (error) { return error instanceof Error ? error.message : 'Cloud unbind failed' @@ -59,27 +55,25 @@ const app = new Hono() .use(requireAdmin) .post('/pair', async (c) => { - const db = c.get('platform').db const baseUrl = getCloudBaseUrl(c) - const instance = await buildCloudInstanceInfo(db, { + const instance = await buildCloudInstanceInfo(c.get('deps'), { url: await getInstanceOrigin(c), runtime: runtimeInfo(c.get('platform')), }) - const pairing = await createPairing(baseUrl, instance) + const pairing = await c.get('deps').licensingCloud.createPairing(baseUrl, instance) return c.json(pairing) }) .get('/pair/:code/poll', async (c) => { const { code } = c.req.param() - const db = c.get('platform').db const baseUrl = getCloudBaseUrl(c) - const result = await pollPairing(baseUrl, code) + const result = await c.get('deps').licensingCloud.pollPairing(baseUrl, code) if (result.status === 'approved') { - const instanceId = await getOrCreateInstanceId(db) + const instanceId = await c.get('deps').instance.getOrCreateInstanceId() const verification = result.certificate ? verifyCertificateResult(result.certificate, { instanceId, @@ -93,13 +87,13 @@ const app = new Hono() // certificate (most often: signed by a key ZPan doesn't trust). Roll back // the orphaned cloud binding so the two sides don't drift and retries don't // pile up dangling bindings. - const cloudUnbindError = await rollbackCloudBinding(baseUrl, result) + const cloudUnbindError = await rollbackCloudBinding(c.get('deps').licensingCloud, baseUrl, result) const reason = verification ? (verification.ok ? 'incomplete_response' : verification.reason) : 'no_certificate' return c.json({ error: 'invalid_certificate', reason, cloud_unbind_error: cloudUnbindError }, 502) } const assertion = verification.assertion - await createLicenseBinding(db, { + await c.get('deps').licenseBinding.createLicenseBinding({ cloudBindingId: result.binding.id, cloudStoreId: result.binding.storeId, instanceId, @@ -118,14 +112,14 @@ const app = new Hono() // the binding is already active locally, so a failed confirm only leaves the // cloud page waiting — it does not break licensing here. try { - await confirmCloudLicense(baseUrl, result.binding.id, result.refreshToken) + await c.get('deps').licensingCloud.confirmCloudLicense(baseUrl, result.binding.id, result.refreshToken) } catch { // ignore — binding works regardless; cloud page falls back to its timeout state } const userId = c.get('userId')! const orgId = c.get('orgId')! - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'license_pair', @@ -145,20 +139,19 @@ const app = new Hono() }) .post('/refresh', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! const baseUrl = getCloudBaseUrl(c) - const instance = await buildCloudInstanceInfo(db, { + const instance = await buildCloudInstanceInfo(c.get('deps'), { url: await getInstanceOrigin(c), runtime: runtimeInfo(c.get('platform')), }) - await performRefresh(db, baseUrl, instance) + await performRefresh(c.get('deps'), baseUrl, instance) - const state = await loadLicenseState(db) + const state = await c.get('deps').licenseBinding.loadLicenseState() - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'license_refresh', @@ -170,25 +163,24 @@ const app = new Hono() }) .delete('/binding', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! const baseUrl = getCloudBaseUrl(c) - const state = await loadLicenseState(db) + const state = await c.get('deps').licenseBinding.loadLicenseState() let cloudUnbindError: string | null = null if (state.refreshToken) { try { - await unbindCloudLicense(baseUrl, state.cloudBindingId, state.refreshToken) + await c.get('deps').licensingCloud.unbindCloudLicense(baseUrl, state.cloudBindingId, state.refreshToken) } catch (error) { cloudUnbindError = error instanceof Error ? error.message : 'Cloud unbind failed' } } - await clearLicenseBinding(db) + await c.get('deps').licenseBinding.clearLicenseBinding() invalidateEntitlementCache() - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'license_disconnect', diff --git a/server/routes/licensing.integration.test.ts b/server/http/licensing.integration.test.ts similarity index 87% rename from server/routes/licensing.integration.test.ts rename to server/http/licensing.integration.test.ts index 2dba3086..fd42e9d7 100644 --- a/server/routes/licensing.integration.test.ts +++ b/server/http/licensing.integration.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLicenseBindingRepo } from '../adapters/repos/license-binding.js' import { cloudTrafficReports } from '../db/schema.js' -import { createLicenseBinding } from '../licensing/license-state.js' import { createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js' describe('GET /api/licensing/status', () => { - it('returns { bound: false } when no binding row exists', async () => { + it('returns { bound: false } when no binding row exists [spec: licensing/state-unbound]', async () => { const { app } = await createTestApp() const res = await app.request('/api/licensing/status') @@ -17,10 +17,10 @@ describe('GET /api/licensing/status', () => { }) }) - it('returns bound state with plan and features when binding row exists with cert', async () => { + it('returns bound state with plan and features when binding row exists with cert [spec: licensing/state-bound]', async () => { const { app, db } = await createTestApp() - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'bind-1', cloudStoreId: 'store-1', instanceId: 'inst-1', @@ -43,10 +43,10 @@ describe('GET /api/licensing/status', () => { expect(body.refreshToken).toBeUndefined() }) - it('returns bound:true with no plan/features when cachedCert is null', async () => { + it('returns bound:true with no plan/features when cachedCert is null [spec: licensing/state-bound-no-cert]', async () => { const { app, db } = await createTestApp() - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'bind-1', cloudStoreId: 'store-1', instanceId: 'inst-1', @@ -64,7 +64,7 @@ describe('GET /api/licensing/status', () => { expect(body.bound).toBe(true) }) - it('is accessible without authentication', async () => { + it('is accessible without authentication [spec: licensing/public]', async () => { const { app } = await createTestApp() const res = await app.request('/api/licensing/status') expect(res.status).toBe(200) @@ -90,7 +90,7 @@ describe('POST /api/licensing/refresh-cron', () => { vi.unstubAllGlobals() }) - it('returns 401 when REFRESH_CRON_SECRET env is not set', async () => { + it('returns 401 when REFRESH_CRON_SECRET env is not set [spec: licensing/refresh-auth]', async () => { const { app } = await createTestApp() const res = await app.request('/api/licensing/refresh-cron?secret=anything', { method: 'POST' }) @@ -118,7 +118,7 @@ describe('POST /api/licensing/refresh-cron', () => { expect(res.status).toBe(401) }) - it('returns 200 with { ok: true } when secret is correct and no binding exists', async () => { + it('returns 200 with { ok: true } when secret is correct and no binding exists [spec: licensing/refresh-noop]', async () => { const { app } = await createTestApp({ REFRESH_CRON_SECRET: 'correct-secret' }) const res = await app.request('/api/licensing/refresh-cron?secret=correct-secret', { method: 'POST' }) @@ -128,7 +128,7 @@ describe('POST /api/licensing/refresh-cron', () => { expect(body.ok).toBe(true) }) - it('returns 200 with { ok: true } and calls refresh when binding exists with old lastRefreshAt', async () => { + it('returns 200 with { ok: true } and calls refresh when binding exists with old lastRefreshAt [spec: licensing/refresh-runs]', async () => { const { app, db } = await createTestApp({ REFRESH_CRON_SECRET: 'cron-secret' }) await seedProLicense(db) @@ -147,7 +147,7 @@ describe('POST /api/licensing/refresh-cron', () => { expect(body.ok).toBe(true) }) - it('returns 200 with { ok: true } even when performRefresh throws (error is swallowed)', async () => { + it('returns 200 with { ok: true } even when performRefresh throws (error is swallowed) [spec: licensing/refresh-error-swallowed]', async () => { const { app, db } = await createTestApp({ REFRESH_CRON_SECRET: 'cron-secret' }) await seedProLicense(db) @@ -163,7 +163,7 @@ describe('POST /api/licensing/refresh-cron', () => { expect(body.ok).toBe(true) }) - it('is accessible without authentication (public route)', async () => { + it('is accessible without authentication (public route) [spec: licensing/traffic-cron-public]', async () => { const { app } = await createTestApp({ REFRESH_CRON_SECRET: 'my-secret' }) const res = await app.request('/api/licensing/refresh-cron?secret=my-secret', { method: 'POST' }) @@ -172,7 +172,7 @@ describe('POST /api/licensing/refresh-cron', () => { expect(res.status).toBe(200) }) - it('syncs pending traffic reports from the dedicated traffic cron endpoint', async () => { + it('syncs pending traffic reports from the dedicated traffic cron endpoint [spec: licensing/traffic-sync]', async () => { const { app, db } = await createTestApp({ REFRESH_CRON_SECRET: 'traffic-secret', ZPAN_CLOUD_URL: 'https://cloud.example', @@ -205,7 +205,7 @@ describe('POST /api/licensing/refresh-cron', () => { await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'reported' }]) }) - it('requires the cron secret for the dedicated traffic cron endpoint', async () => { + it('requires the cron secret for the dedicated traffic cron endpoint [spec: licensing/traffic-cron-secret]', async () => { const { app } = await createTestApp({ REFRESH_CRON_SECRET: 'traffic-secret' }) const res = await app.request('/api/licensing/traffic-sync-runs?secret=wrong-secret', { method: 'POST' }) diff --git a/server/routes/licensing.ts b/server/http/licensing.ts similarity index 74% rename from server/routes/licensing.ts rename to server/http/licensing.ts index 528edb09..eead28d8 100644 --- a/server/routes/licensing.ts +++ b/server/http/licensing.ts @@ -3,14 +3,15 @@ import type { Context } from 'hono' import { Hono } from 'hono' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import type { BindingState } from '../../shared/types' -import { loadBindingState } from '../licensing/has-feature' -import { buildCloudInstanceInfo, runtimeInfo } from '../licensing/instance-info' -import { normalizeHost } from '../licensing/verify' +import { originFromRequestUrl } from '../domain/site-public-origin' import type { Env } from '../middleware/platform' -import { syncPendingCloudTrafficReports } from '../services/cloud-traffic-metering' -import { runLicensingRefresh } from '../services/licensing-refresh-runner' -import { syncPendingRemoteDownloadUsageReports } from '../services/remote-download-usage' -import { getSitePublicOrigin, originFromRequestUrl } from '../services/site-public-origin' +import { syncPendingCloudTrafficReports } from '../usecases/cloud-traffic-metering' +import { buildCloudInstanceInfo, runtimeInfo } from '../usecases/instance-info' +import { normalizeHost } from '../usecases/license-certificate' +import { loadBindingState } from '../usecases/licensing' +import { runLicensingRefresh } from '../usecases/licensing-refresh-runner' +import { syncPendingRemoteDownloadUsageReports } from '../usecases/remote-download-usage' +import { getSitePublicOrigin } from '../usecases/site-public-origin' async function configuredPublicHost(c: Context): Promise { const origin = await getInstanceOrigin(c) @@ -18,7 +19,7 @@ async function configuredPublicHost(c: Context): Promise { } async function getInstanceOrigin(c: Context): Promise { - return (await getSitePublicOrigin(c.get('platform').db)) ?? originFromRequestUrl(c.req.url) + return (await getSitePublicOrigin(c.get('deps'))) ?? originFromRequestUrl(c.req.url) } function cloudDashboardUrl(cloudBaseUrl: string): string { @@ -33,13 +34,12 @@ function secretsMatch(provided: string, expected: string): boolean { const app = new Hono() .get('/status', async (c) => { - const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT const currentHost = (await configuredPublicHost(c)) ?? normalizeHost(c.req.header('x-forwarded-host') ?? c.req.header('host')) ?? new URL(c.req.url).host - const state = await loadBindingState(db, { currentHost, cloudBaseUrl }) + const state = await loadBindingState(c.get('deps'), { currentHost, cloudBaseUrl }) return c.json({ ...state, cloud_dashboard_url: cloudDashboardUrl(cloudBaseUrl) } satisfies BindingState) }) @@ -53,16 +53,15 @@ const app = new Hono() return c.json({ error: 'Unauthorized' }, 401) } - const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT const origin = await getInstanceOrigin(c) const instance = origin - ? await buildCloudInstanceInfo(db, { + ? await buildCloudInstanceInfo(c.get('deps'), { url: origin, runtime: runtimeInfo(c.get('platform')), }) : undefined - await runLicensingRefresh(db, cloudBaseUrl, instance) + await runLicensingRefresh(c.get('deps'), cloudBaseUrl, instance) return c.json({ ok: true }) }) @@ -72,11 +71,10 @@ const app = new Hono() return c.json({ error: 'Unauthorized' }, 401) } - const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT const [traffic, remoteDownload] = await Promise.all([ - syncPendingCloudTrafficReports({ db, cloudBaseUrl }), - syncPendingRemoteDownloadUsageReports({ db, cloudBaseUrl }), + syncPendingCloudTrafficReports(c.get('deps'), { cloudBaseUrl }), + syncPendingRemoteDownloadUsageReports(c.get('deps'), { cloudBaseUrl }), ]) return c.json({ ok: true, ...traffic, remoteDownload }) diff --git a/server/routes/me.integration.test.ts b/server/http/me.integration.test.ts similarity index 87% rename from server/routes/me.integration.test.ts rename to server/http/me.integration.test.ts index d14bbc5b..2c633f7a 100644 --- a/server/routes/me.integration.test.ts +++ b/server/http/me.integration.test.ts @@ -1,6 +1,6 @@ import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { S3Service } from '../services/s3.js' +import { S3Service } from '../adapters/gateways/s3.js' import { authedHeaders, createTestApp } from '../test/setup.js' type TestDb = Awaited>['db'] @@ -23,7 +23,7 @@ describe('PUT /api/me/avatar', () => { vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(16) }) - it('returns 401 without auth', async () => { + it('returns 401 without auth [spec: avatar/auth-required]', async () => { const { app } = await createTestApp() const form = new FormData() form.set('file', makeFile('image/png')) @@ -31,7 +31,7 @@ describe('PUT /api/me/avatar', () => { expect(res.status).toBe(401) }) - it('returns 415 when Content-Type is not multipart', async () => { + it('returns 415 when Content-Type is not multipart [spec: avatar/multipart-required]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/me/avatar', { @@ -42,7 +42,7 @@ describe('PUT /api/me/avatar', () => { expect(res.status).toBe(415) }) - it('returns 400 when file field is missing', async () => { + it('returns 400 when file field is missing [spec: avatar/file-required]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const form = new FormData() @@ -51,7 +51,7 @@ describe('PUT /api/me/avatar', () => { expect(res.status).toBe(400) }) - it('returns 400 when mime is not PNG/JPG/WebP', async () => { + it('returns 400 when mime is not PNG/JPG/WebP [spec: avatar/mime-validated]', async () => { const { app, db } = await createTestApp() await insertPublicStorage(db) const headers = await authedHeaders(app) @@ -61,7 +61,7 @@ describe('PUT /api/me/avatar', () => { expect(res.status).toBe(400) }) - it('returns 413 when file exceeds 2 MiB', async () => { + it('returns 413 when file exceeds 2 MiB [spec: avatar/size-limit]', async () => { const { app, db } = await createTestApp() await insertPublicStorage(db) const headers = await authedHeaders(app) @@ -71,7 +71,7 @@ describe('PUT /api/me/avatar', () => { expect(res.status).toBe(413) }) - it('returns 503 when no public storage is configured', async () => { + it('returns 503 when no public storage is configured [spec: avatar/needs-storage]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const form = new FormData() @@ -80,7 +80,7 @@ describe('PUT /api/me/avatar', () => { expect(res.status).toBe(503) }) - it('uploads the file to S3, writes user.image, returns the URL', async () => { + it('uploads the file to S3, writes user.image, returns the URL [spec: avatar/upload]', async () => { const { app, db } = await createTestApp() await insertPublicStorage(db) const headers = await authedHeaders(app) @@ -98,7 +98,7 @@ describe('PUT /api/me/avatar', () => { expect(rows[0]?.image).toBe(body.url) }) - it('is idempotent — re-PUT with same mime returns the same URL', async () => { + it('is idempotent — re-PUT with same mime returns the same URL [spec: avatar/idempotent]', async () => { const { app, db } = await createTestApp() await insertPublicStorage(db) const headers = await authedHeaders(app) @@ -129,7 +129,7 @@ describe('DELETE /api/me/avatar', () => { expect(res.status).toBe(401) }) - it('clears user.image and removes all mime variants from S3', async () => { + it('clears user.image and removes all mime variants from S3 [spec: avatar/delete]', async () => { const { app, db } = await createTestApp() await insertPublicStorage(db) const headers = await authedHeaders(app) @@ -144,7 +144,7 @@ describe('DELETE /api/me/avatar', () => { expect(S3Service.prototype.deleteObject).toHaveBeenCalledTimes(3) }) - it('succeeds when no public storage exists (DB cleared, S3 skipped)', async () => { + it('succeeds when no public storage exists (DB cleared, S3 skipped) [spec: avatar/delete-no-storage]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await db.run(sql`UPDATE user SET image = 'https://example.com/old.png'`) diff --git a/server/routes/me.ts b/server/http/me.ts similarity index 70% rename from server/routes/me.ts rename to server/http/me.ts index f10e98a8..00497eda 100644 --- a/server/routes/me.ts +++ b/server/http/me.ts @@ -1,9 +1,6 @@ -import { eq } from 'drizzle-orm' import { Hono } from 'hono' -import { user } from '../db/auth-schema' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { deletePublicImageVariants, uploadPublicImage } from '../services/image-upload' const AVATAR_PREFIX = '_system/avatars' @@ -22,10 +19,10 @@ export const me = new Hono() const file = form.get('file') if (!(file instanceof File)) return c.json({ error: 'file field is required' }, 400) - const result = await uploadPublicImage(platform, AVATAR_PREFIX, userId, file) + const result = await c.get('deps').imageUpload.uploadPublicImage(platform, AVATAR_PREFIX, userId, file) if (!result.ok) return c.json({ error: result.error }, result.status) - await platform.db.update(user).set({ image: result.url }).where(eq(user.id, userId)) + await c.get('deps').profiles.setAvatar(userId, result.url) return c.json({ url: result.url }) }) .delete('/avatar', async (c) => { @@ -33,7 +30,7 @@ export const me = new Hono() const userId = c.get('userId') as string // Clear DB first (authoritative); storage cleanup below is best-effort. - await platform.db.update(user).set({ image: null }).where(eq(user.id, userId)) - await deletePublicImageVariants(platform, AVATAR_PREFIX, userId) + await c.get('deps').profiles.setAvatar(userId, null) + await c.get('deps').imageUpload.deletePublicImageVariants(platform, AVATAR_PREFIX, userId) return c.json({ ok: true }) }) diff --git a/server/routes/notifications.cf-test.ts b/server/http/notifications.cf-test.ts similarity index 100% rename from server/routes/notifications.cf-test.ts rename to server/http/notifications.cf-test.ts diff --git a/server/routes/notifications.integration.test.ts b/server/http/notifications.integration.test.ts similarity index 80% rename from server/routes/notifications.integration.test.ts rename to server/http/notifications.integration.test.ts index a80acdb0..46a9b313 100644 --- a/server/routes/notifications.integration.test.ts +++ b/server/http/notifications.integration.test.ts @@ -1,7 +1,7 @@ import { nanoid } from 'nanoid' import { describe, expect, it } from 'vitest' +import { createNotificationRepo } from '../adapters/repos/notification.js' import * as authSchema from '../db/auth-schema.js' -import { createNotification } from '../services/notification.js' import { createTestApp } from '../test/setup.js' type TestDb = Awaited>['db'] @@ -34,7 +34,7 @@ async function signUpAndGetUser(app: TestApp, email: string) { // ─── Auth guard ─────────────────────────────────────────────────────────────── describe('GET /api/notifications (auth guard)', () => { - it('returns 401 without auth', async () => { + it('returns 401 without auth [spec: notifications/auth]', async () => { const { app } = await createTestApp() const res = await app.request('/api/notifications') expect(res.status).toBe(401) @@ -56,12 +56,12 @@ describe('GET /api/notifications', () => { expect(body.unreadCount).toBe(0) }) - it('returns notifications with pagination', async () => { + it('returns notifications with pagination [spec: notifications/list]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`) 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 res = await app.request('/api/notifications?page=1&pageSize=3', { headers }) @@ -73,12 +73,12 @@ describe('GET /api/notifications', () => { expect(body.pageSize).toBe(3) }) - it('filters unread notifications', async () => { + it('filters unread notifications [spec: notifications/unread-filter]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`) - const n1 = await createNotification(db, { userId, type: 'share_received', title: 'Read' }) - await createNotification(db, { userId, type: 'share_received', title: 'Unread' }) + const n1 = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'Read' }) + await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'Unread' }) await app.request(`/api/notifications/${n1.id}`, { method: 'PATCH', @@ -93,11 +93,11 @@ describe('GET /api/notifications', () => { expect(body.items[0].title).toBe('Unread') }) - it('does not return other users notifications', async () => { + it('does not return other users notifications [spec: notifications/isolation]', async () => { const { app, db } = await createTestApp() const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`) const otherId = await insertUser(db) - await createNotification(db, { userId: otherId, type: 'share_received', title: 'Other' }) + await createNotificationRepo(db).create({ userId: otherId, type: 'share_received', title: 'Other' }) const res = await app.request('/api/notifications', { headers }) expect(res.status).toBe(200) @@ -109,12 +109,12 @@ describe('GET /api/notifications', () => { // ─── GET /api/notifications/stats ───────────────────────────────────── describe('GET /api/notifications/stats', () => { - it('returns correct count', async () => { + it('returns correct count [spec: notifications/stats]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`) - 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 res = await app.request('/api/notifications/stats', { headers }) expect(res.status).toBe(200) @@ -126,10 +126,10 @@ describe('GET /api/notifications/stats', () => { // ─── PATCH /api/notifications/:id ──────────────────────────────────────── describe('PATCH /api/notifications/:id', () => { - it('marks notification as read and returns 204', async () => { + it('marks notification as read and returns 204 [spec: notifications/mark-read]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`) - const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' }) + const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'Test' }) const res = await app.request(`/api/notifications/${n.id}`, { method: 'PATCH', @@ -146,7 +146,7 @@ describe('PATCH /api/notifications/:id', () => { it('is idempotent', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`) - const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' }) + const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'Test' }) await app.request(`/api/notifications/${n.id}`, { method: 'PATCH', @@ -161,11 +161,11 @@ describe('PATCH /api/notifications/:id', () => { expect(res.status).toBe(204) }) - it('returns 404 for a notification owned by another user', async () => { + it('returns 404 for a notification owned by another user [spec: notifications/mark-read-foreign]', async () => { const { app, db } = await createTestApp() const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`) const otherId = await insertUser(db) - const n = await createNotification(db, { userId: otherId, type: 'share_received', title: 'Other' }) + const n = await createNotificationRepo(db).create({ userId: otherId, type: 'share_received', title: 'Other' }) const res = await app.request(`/api/notifications/${n.id}`, { method: 'PATCH', @@ -191,12 +191,12 @@ describe('PATCH /api/notifications/:id', () => { // ─── PATCH /api/notifications ──────────────────────────────────────── describe('PATCH /api/notifications', () => { - it('marks all notifications as read and returns count', async () => { + it('marks all notifications as read and returns count [spec: notifications/mark-all]', async () => { const { app, db } = await createTestApp() const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`) - 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 res = await app.request('/api/notifications', { method: 'PATCH', @@ -216,7 +216,7 @@ describe('PATCH /api/notifications', () => { const { app, db } = await createTestApp() const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`) const otherId = await insertUser(db) - await createNotification(db, { userId: otherId, type: 'share_received', title: 'Other' }) + await createNotificationRepo(db).create({ userId: otherId, type: 'share_received', title: 'Other' }) const res = await app.request('/api/notifications', { method: 'PATCH', diff --git a/server/routes/notifications.ts b/server/http/notifications.ts similarity index 69% rename from server/routes/notifications.ts rename to server/http/notifications.ts index eb81f680..8f0e56d7 100644 --- a/server/routes/notifications.ts +++ b/server/http/notifications.ts @@ -3,40 +3,35 @@ import { Hono } from 'hono' import { listNotificationsQuerySchema } from '../../shared/schemas' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { listNotifications, markAllAsRead, markAsRead, unreadCount } from '../services/notification' export const notifications = new Hono() .use(requireAuth) .get('/', zValidator('query', listNotificationsQuerySchema), async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const { page: pageStr, pageSize: pageSizeStr, unread } = c.req.valid('query') const page = Number(pageStr ?? '1') const pageSize = Number(pageSizeStr ?? '20') const unreadOnly = unread === 'true' - const result = await listNotifications(db, userId, { page, pageSize, unreadOnly }) + const result = await c.get('deps').notifications.list(userId, { page, pageSize, unreadOnly }) return c.json({ ...result, page, pageSize }) }) .get('/stats', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! - const count = await unreadCount(db, userId) + const count = await c.get('deps').notifications.unreadCount(userId) return c.json({ count }) }) .patch('/:id', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const { id } = c.req.param() - const found = await markAsRead(db, userId, id) + const found = await c.get('deps').notifications.markAsRead(userId, id) if (!found) return c.json({ error: 'Not found' }, 404) return new Response(null, { status: 204 }) }) .patch('/', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! - const result = await markAllAsRead(db, userId) + const result = await c.get('deps').notifications.markAllAsRead(userId) return c.json(result) }) diff --git a/server/routes/object-multipart-live.integration.test.ts b/server/http/object-multipart-live.integration.test.ts similarity index 100% rename from server/routes/object-multipart-live.integration.test.ts rename to server/http/object-multipart-live.integration.test.ts diff --git a/server/routes/objects-quota.integration.test.ts b/server/http/objects-quota.integration.test.ts similarity index 99% rename from server/routes/objects-quota.integration.test.ts rename to server/http/objects-quota.integration.test.ts index 8e046aaa..1ae32d72 100644 --- a/server/routes/objects-quota.integration.test.ts +++ b/server/http/objects-quota.integration.test.ts @@ -1,9 +1,9 @@ import { eq, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3.js' import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js' -import { currentTrafficPeriod } from '../services/effective-quota.js' -import { S3Service } from '../services/s3.js' +import { currentTrafficPeriod } from '../domain/quota.js' import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js' beforeEach(() => { diff --git a/server/routes/objects.cf-test.ts b/server/http/objects.cf-test.ts similarity index 100% rename from server/routes/objects.cf-test.ts rename to server/http/objects.cf-test.ts diff --git a/server/routes/objects.integration.test.ts b/server/http/objects.integration.test.ts similarity index 90% rename from server/routes/objects.integration.test.ts rename to server/http/objects.integration.test.ts index f032974e..c948bd28 100644 --- a/server/routes/objects.integration.test.ts +++ b/server/http/objects.integration.test.ts @@ -1,18 +1,66 @@ import { sql } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3.js' +import { createActivityRepo } from '../adapters/repos/activity.js' +import { createMatterRepo } from '../adapters/repos/matter.js' +import { createQuotaRepo } from '../adapters/repos/quota.js' +import { createStorageUsageRepo } from '../adapters/repos/storage-usage.js' import { cloudTrafficReports } from '../db/schema.js' -import { - confirmUpload, - copyMatter, - createMatter, - deleteMatter, - getMatter, - getMatters, - listMatters, - updateMatter, -} from '../services/matter.js' -import { S3Service } from '../services/s3.js' import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js' +import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../usecases/matter.js' +import type { + CopyMatterOptions, + CreateMatterInput, + Matter, + MatterListFilters, + UpdateMatterInput, +} from '../usecases/ports.js' + +type TestDbForMatter = Awaited>['db'] + +// Thin adapters preserving the former matter service signatures so these +// behavioral tests exercise the migrated MatterRepo + confirmUpload usecase +// unchanged. +function createMatter(db: TestDbForMatter, input: CreateMatterInput): Promise { + return createMatterRepo(db).create(input) +} +function getMatter(db: TestDbForMatter, id: string, orgId: string) { + return createMatterRepo(db).get(id, orgId) +} +function getMatters(db: TestDbForMatter, orgId: string, ids: string[]) { + return createMatterRepo(db).getMany(orgId, ids) +} +function listMatters(db: TestDbForMatter, orgId: string, filters: MatterListFilters) { + return createMatterRepo(db).list(orgId, filters) +} +function updateMatter(db: TestDbForMatter, id: string, orgId: string, input: UpdateMatterInput, userId?: string) { + return createMatterRepo(db).update(id, orgId, input, userId) +} +function copyMatter( + db: TestDbForMatter, + source: Matter, + targetParent: string, + newObject: string, + opts?: CopyMatterOptions, +) { + return createMatterRepo(db).copy(source, targetParent, newObject, opts) +} +function deleteMatter(db: TestDbForMatter, id: string, orgId: string) { + return createMatterRepo(db).delete(id, orgId) +} +function confirmUpload(db: TestDbForMatter, id: string, orgId: string, opts: ConfirmUploadOptions = {}) { + return confirmUploadUsecase( + { + matter: createMatterRepo(db), + quota: createQuotaRepo(db), + storageUsage: createStorageUsageRepo(db), + activity: createActivityRepo(db), + }, + id, + orgId, + opts, + ) +} beforeEach(() => { vi.restoreAllMocks() @@ -94,13 +142,13 @@ async function getOrgId(db: Awaited>['db']): Pr } describe('Objects API', () => { - it('returns 401 without auth', async () => { + it('returns 401 without auth [spec: objects/auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/objects') expect(res.status).toBe(401) }) - it('GET /api/objects returns empty list', async () => { + it('GET /api/objects returns empty list [spec: objects/list-empty]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { headers }) @@ -109,7 +157,7 @@ describe('Objects API', () => { expect(body).toEqual({ items: [], total: 0, page: 1, pageSize: 20 }) }) - it('GET /api/objects respects pagination params', async () => { + it('GET /api/objects respects pagination params [spec: objects/list-pagination]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects?page=2&pageSize=10', { headers }) @@ -119,7 +167,7 @@ describe('Objects API', () => { expect(body.pageSize).toBe(10) }) - it('POST /api/objects creates a folder', async () => { + it('POST /api/objects creates a folder [spec: objects/create-folder]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -137,7 +185,7 @@ describe('Objects API', () => { expect(body.id).toBeTruthy() }) - it('POST /api/objects returns 400 for invalid input', async () => { + it('POST /api/objects returns 400 for invalid input [spec: objects/create-invalid]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { @@ -148,7 +196,7 @@ describe('Objects API', () => { expect(res.status).toBe(400) }) - it('POST /api/objects returns 500 when no storage available', async () => { + it('POST /api/objects returns 500 when no storage available [spec: objects/create-no-storage]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects', { @@ -179,7 +227,7 @@ describe('Objects API', () => { expect(body.items[1].name).toBe('file.txt') }) - it('GET /api/objects filters by parent', async () => { + it('GET /api/objects filters by parent [spec: objects/list-by-parent]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -195,7 +243,7 @@ describe('Objects API', () => { expect(body.items[0].name).toBe('nested.txt') }) - it('GET /api/objects filters by status', async () => { + it('GET /api/objects filters by status [spec: objects/list-by-status]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -210,7 +258,7 @@ describe('Objects API', () => { expect(body.items[0].name).toBe('draft.txt') }) - it('GET /api/objects/:id returns folder detail', async () => { + it('GET /api/objects/:id returns folder detail [spec: objects/detail]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -226,14 +274,14 @@ describe('Objects API', () => { expect(body).not.toHaveProperty('downloadUrl') }) - it('GET /api/objects/:id returns 404 for missing object', async () => { + it('GET /api/objects/:id returns 404 for missing object [spec: objects/detail-missing]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/objects/nonexistent', { headers }) expect(res.status).toBe(404) }) - it('PATCH /api/objects/:id renames an object', async () => { + it('PATCH /api/objects/:id renames an object [spec: objects/rename]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -250,7 +298,7 @@ describe('Objects API', () => { expect(body.name).toBe('New Name') }) - it('PATCH /api/objects/:id moves an object', async () => { + it('PATCH /api/objects/:id moves an object [spec: objects/move]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -279,7 +327,7 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) - it('PATCH /api/objects/:id (action: confirm) confirms upload', async () => { + it('PATCH /api/objects/:id (action: confirm) confirms upload [spec: objects/confirm-upload]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -296,7 +344,7 @@ describe('Objects API', () => { expect(body.status).toBe('active') }) - it('PATCH /api/objects/:id (action: confirm) returns 404 for non-draft object', async () => { + it('PATCH /api/objects/:id (action: confirm) returns 404 for non-draft object [spec: objects/confirm-non-draft]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -311,7 +359,7 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) - it('PATCH /api/objects/:id (action: cancel) deletes a draft upload and cleans up S3', async () => { + it('PATCH /api/objects/:id (action: cancel) deletes a draft upload and cleans up S3 [spec: objects/cancel-draft]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -352,7 +400,7 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) - it('DELETE /api/objects/:id rejects active object (must trash first)', async () => { + it('DELETE /api/objects/:id rejects active object (must trash first) [spec: objects/delete-requires-trash]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -363,7 +411,7 @@ describe('Objects API', () => { expect(res.status).toBe(409) }) - it('DELETE /api/objects/:id permanently deletes a trashed folder', async () => { + it('DELETE /api/objects/:id permanently deletes a trashed folder [spec: objects/purge-folder]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -412,7 +460,7 @@ describe('Objects API', () => { expect(await getMatter(db, 'movie-file', orgId)).toBeNull() }) - it('PATCH /api/objects/:id (action: trash) trashes a file', async () => { + it('PATCH /api/objects/:id (action: trash) trashes a file [spec: objects/trash]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -434,7 +482,7 @@ describe('Objects API', () => { expect(listBody.total).toBe(1) }) - it('PATCH /api/objects/:id (action: restore) restores a trashed file', async () => { + it('PATCH /api/objects/:id (action: restore) restores a trashed file [spec: objects/restore]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -451,7 +499,7 @@ describe('Objects API', () => { expect(body.status).toBe('active') }) - it('PATCH /api/objects/:id (action: trash) cascades to folder children', async () => { + it('PATCH /api/objects/:id (action: trash) cascades to folder children [spec: objects/trash-cascade]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -484,7 +532,7 @@ describe('Objects API', () => { expect(childBody.status).toBe('active') }) - it('GET /api/objects?status=trashed returns trashed folder roots nested under active parents', async () => { + it('GET /api/objects?status=trashed returns trashed folder roots nested under active parents [spec: objects/list-trashed]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -509,7 +557,7 @@ describe('Objects API', () => { expect(body.items.map((item) => item.id)).toEqual(['album']) }) - it('DELETE /api/trash purges all trashed items', async () => { + it('DELETE /api/trash purges all trashed items [spec: objects/purge-all]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -536,7 +584,7 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) - it('POST /api/objects/copy copies a folder', async () => { + it('POST /api/objects/copy copies a folder [spec: objects/copy-folder]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -579,7 +627,7 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) - it('POST /api/objects creates a file with upload URL', async () => { + it('POST /api/objects creates a file with upload URL [spec: objects/create-file-presign]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -595,7 +643,7 @@ describe('Objects API', () => { expect(body.object).toBeTruthy() }) - it('POST /api/objects/copy copies a file with S3', async () => { + it('POST /api/objects/copy copies a file with S3 [spec: objects/copy-file]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -611,7 +659,7 @@ describe('Objects API', () => { expect(S3Service.prototype.copyObject).toHaveBeenCalled() }) - it('DELETE /api/objects/:id permanently deletes a trashed file with S3 cleanup', async () => { + it('DELETE /api/objects/:id permanently deletes a trashed file with S3 cleanup [spec: objects/purge-file-s3]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -748,7 +796,7 @@ describe('Objects API', () => { expect(body.purged).toBe(0) }) - it('GET /api/objects/:id returns downloadUrl for files', async () => { + it('GET /api/objects/:id returns downloadUrl for files [spec: objects/download-url]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -761,7 +809,7 @@ describe('Objects API', () => { expect(body.downloadUrl).toBe('https://presigned-download.example.com') }) - it('GET /api/objects/:id reports Cloud traffic for bound instances before returning the URL', async () => { + it('GET /api/objects/:id reports Cloud traffic for bound instances before returning the URL [spec: objects/download-traffic]', async () => { const { app, db } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' }) const headers = await authedHeaders(app) await insertStorage(db, { metered: true }) @@ -983,7 +1031,7 @@ describe('Matter service', () => { // ─── Name-conflict route layer ──────────────────────────────────────────────── describe('Objects API — name conflict (409 responses)', () => { - it('POST /api/objects returns 409 with NAME_CONFLICT code when folder name is already taken', async () => { + it('POST /api/objects returns 409 with NAME_CONFLICT code when folder name is already taken [spec: objects/create-conflict]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1003,7 +1051,7 @@ describe('Objects API — name conflict (409 responses)', () => { expect(typeof body.conflictingId).toBe('string') }) - it('POST /api/objects with onConflict: rename succeeds and returns auto-renamed folder', async () => { + it('POST /api/objects with onConflict: rename succeeds and returns auto-renamed folder [spec: objects/create-conflict-rename]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1021,7 +1069,7 @@ describe('Objects API — name conflict (409 responses)', () => { expect(body.name).toBe('Reports (1)') }) - it('PATCH /api/objects/:id rename conflict returns 409 with NAME_CONFLICT code', async () => { + it('PATCH /api/objects/:id rename conflict returns 409 with NAME_CONFLICT code [spec: objects/rename-conflict]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1060,7 +1108,7 @@ describe('Objects API — name conflict (409 responses)', () => { expect(body.name).toBe('beta (1).txt') }) - it('PATCH /api/objects/:id move with collision and no onConflict returns 409', async () => { + it('PATCH /api/objects/:id move with collision and no onConflict returns 409 [spec: objects/move-conflict]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1119,7 +1167,7 @@ describe('Objects API — name conflict (409 responses)', () => { expect(body.code).toBe('NAME_CONFLICT') }) - it('PATCH /api/objects/:id (action: restore) returns 409 when restore name is already taken', async () => { + it('PATCH /api/objects/:id (action: restore) returns 409 when restore name is already taken [spec: objects/restore-conflict]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1249,7 +1297,7 @@ function transferRequest( } describe('POST /api/objects/:id/transfers', () => { - it('copies a file into a team space the user can edit', async () => { + it('copies a file into a team space the user can edit [spec: objects/transfer-copy]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1271,7 +1319,7 @@ describe('POST /api/objects/:id/transfers', () => { expect(source?.status).toBe('active') }) - it('moves a file into a team space, deleting the source and releasing its quota', async () => { + it('moves a file into a team space, deleting the source and releasing its quota [spec: objects/transfer-move]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1298,7 +1346,7 @@ describe('POST /api/objects/:id/transfers', () => { expect(targetList.items.map((m) => m.name)).toContain('photo.jpg') }) - it('copies a folder recursively into the target space', async () => { + it('copies a folder recursively into the target space [spec: objects/transfer-folder]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1316,7 +1364,7 @@ describe('POST /api/objects/:id/transfers', () => { expect(body.saved.map((m) => m.name)).toEqual(expect.arrayContaining(['Album', 'pic.png'])) }) - it('rejects transfer into a team the user is not a member of', async () => { + it('rejects transfer into a team the user is not a member of [spec: objects/transfer-permission]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1344,7 +1392,7 @@ describe('POST /api/objects/:id/transfers', () => { expect(res.status).toBe(403) }) - it('rejects transfer when the target space quota is exceeded', async () => { + it('rejects transfer when the target space quota is exceeded [spec: objects/transfer-quota]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -1361,7 +1409,7 @@ describe('POST /api/objects/:id/transfers', () => { expect(body.code).toBe('QUOTA_EXCEEDED') }) - it('rejects transfer to the same space', async () => { + it('rejects transfer to the same space [spec: objects/transfer-same-space]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) diff --git a/server/routes/objects.ts b/server/http/objects.ts similarity index 77% rename from server/routes/objects.ts rename to server/http/objects.ts index eca18821..1fa6db37 100644 --- a/server/routes/objects.ts +++ b/server/http/objects.ts @@ -14,40 +14,23 @@ import { transferMatterSchema, } from '../../shared/schemas' import { mapDomainError } from '../lib/http-errors' +import { buildObjectKey, fileExt } from '../lib/path-template' import { requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { assertTaskUploadAllowed } from '../services/downloads' -import { refundTraffic } from '../services/effective-quota' -import { - cancelDraftMatter, - collectForPurge, - confirmUpload, - copyMatter, - createMatter, - getMatter, - listMatters, - restoreMatter, - trashMatter, - updateMatter, -} from '../services/matter' +import { assertTaskUploadAllowed } from '../usecases/downloads' +import { confirmUpload } from '../usecases/matter' import { createObjectUploadSession, ObjectUploadSessionError, patchObjectUploadSession, presignObjectUploadParts, -} from '../services/object-upload-sessions' -import { canReadOrg, canWriteToOrg, getMemberRole, isPersonalOrg } from '../services/org' -import { buildObjectKey, fileExt } from '../services/path-template' -import { purgeRecursively } from '../services/purge' -import { S3Service } from '../services/s3' -import { computeSourceBytes, copyMatterToOrg, isQuotaSufficient } from '../services/save-to-drive' -import { getStorage, type Storage as S3Storage, selectStorage } from '../services/storage' -import { withStorageUsageReservation } from '../services/storage-usage' +} from '../usecases/object-upload-session' +import type { StorageRecord as S3Storage } from '../usecases/ports' +import { purgeRecursively } from '../usecases/purge' +import { copyMatterToOrg } from '../usecases/save-to-drive' +import { withStorageUsageReservation } from '../usecases/storage-usage' import { consumeAndReportDownloadTraffic } from './traffic-metering-utils' -const s3 = new S3Service() - function normalizeMatterPath(path: string): string { return path .split('/') @@ -106,7 +89,7 @@ const app = new Hono() // folders of another space the user has access to. const orgOverride = c.req.query('orgId') if (orgOverride && orgOverride !== orgId) { - if (!(await canReadOrg(c.get('platform').db, c.get('userId')!, orgOverride))) { + if (!(await c.get('deps').org.canReadOrg(c.get('userId')!, orgOverride))) { return c.json({ error: 'Forbidden' }, 403) } orgId = orgOverride @@ -119,8 +102,7 @@ const app = new Hono() const page = Number(c.req.query('page') ?? '1') const pageSize = Number(c.req.query('pageSize') ?? '20') - const db = c.get('platform').db - const result = await listMatters(db, orgId, { parent, status, typeFilter, search, page, pageSize }) + const result = await c.get('deps').matter.list(orgId, { parent, status, typeFilter, search, page, pageSize }) return c.json(result) }) .post('/', requireObjectCreateAccess, zValidator('json', createMatterSchema), async (c) => { @@ -128,7 +110,6 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db const userId = principal?.kind === 'download-task-upload' ? principal.createdByUserId : (c.get('userId') as string) const actorId = principal?.kind === 'download-task-upload' ? `downloader:${principal.downloaderId}` : (c.get('userId') as string) @@ -137,7 +118,7 @@ const app = new Hono() if (principal?.kind === 'download-task-upload') { if (!isWithinDownloadTarget(parent, principal.targetFolder)) return c.json({ error: 'Target folder is outside task authorization' }, 403) - await assertTaskUploadAllowed(c.get('platform'), { + await assertTaskUploadAllowed(c.get('deps'), { taskId: principal.taskId, downloaderId: principal.downloaderId, }) @@ -145,7 +126,7 @@ const app = new Hono() let storage: S3Storage try { - storage = await selectStorage(db, 'private') + storage = await c.get('deps').storages.select('private') } catch (error) { if (error instanceof Error && error.message === 'No available storage') { return c.json({ error: 'Storage not configured' }, 500) @@ -161,7 +142,7 @@ const app = new Hono() }) try { - const matter = await createMatter(db, { + const matter = await c.get('deps').matter.create({ orgId, name, type: isFolder ? 'folder' : type, @@ -176,7 +157,7 @@ const app = new Hono() }) if (isFolder) return c.json(matter, 201) const contentDisposition = attachmentContentDisposition(name) - const uploadUrl = await s3.presignUpload(storage, objectKey, type, name) + const uploadUrl = await c.get('deps').s3.presignUpload(storage, objectKey, type, name) return c.json({ ...matter, uploadUrl, contentDisposition }, 201) } catch (e) { const mapped = mapDomainError(e) @@ -190,22 +171,22 @@ const app = new Hono() async () => { const orgId = c.get('orgId') if (!orgId) throw new ObjectUploadSessionError('not_found') - const matter = await getMatter(c.get('platform').db, c.req.param('id'), orgId) + const matter = await c.get('deps').matter.get(c.req.param('id'), orgId) if (!matter || matter.status !== 'draft' || matter.dirtype !== DirType.FILE || !matter.object) { throw new ObjectUploadSessionError('not_found') } - const storage = await getStorage(c.get('platform').db, matter.storageId) + const storage = await c.get('deps').storages.get(matter.storageId) if (!storage) throw new ObjectUploadSessionError('not_found') const principal = c.get('principal') if (principal?.kind === 'download-task-upload') { if (!isWithinDownloadTarget(matter.parent, principal.targetFolder)) throw new ObjectUploadSessionError('invalid_state') - await assertTaskUploadAllowed(c.get('platform'), { + await assertTaskUploadAllowed(c.get('deps'), { taskId: principal.taskId, downloaderId: principal.downloaderId, }) } - return createObjectUploadSession(c.get('platform').db, s3, { + return createObjectUploadSession(c.get('deps'), { orgId, objectId: matter.id, storage, @@ -226,11 +207,11 @@ const app = new Hono() objectUploadResponse(c, async () => { const orgId = c.get('orgId') if (!orgId) throw new ObjectUploadSessionError('not_found') - const matter = await getMatter(c.get('platform').db, c.req.param('id'), orgId) + const matter = await c.get('deps').matter.get(c.req.param('id'), orgId) if (!matter) throw new ObjectUploadSessionError('not_found') - const storage = await getStorage(c.get('platform').db, matter.storageId) + const storage = await c.get('deps').storages.get(matter.storageId) if (!storage) throw new ObjectUploadSessionError('not_found') - return presignObjectUploadParts(c.get('platform').db, s3, { + return presignObjectUploadParts(c.get('deps'), { orgId, objectId: matter.id, sessionId: c.req.param('uploadSessionId'), @@ -247,11 +228,11 @@ const app = new Hono() objectUploadResponse(c, async () => { const orgId = c.get('orgId') if (!orgId) throw new ObjectUploadSessionError('not_found') - const matter = await getMatter(c.get('platform').db, c.req.param('id'), orgId) + const matter = await c.get('deps').matter.get(c.req.param('id'), orgId) if (!matter) throw new ObjectUploadSessionError('not_found') - const storage = await getStorage(c.get('platform').db, matter.storageId) + const storage = await c.get('deps').storages.get(matter.storageId) if (!storage) throw new ObjectUploadSessionError('not_found') - return patchObjectUploadSession(c.get('platform').db, s3, { + return patchObjectUploadSession(c.get('deps'), { orgId, objectId: matter.id, sessionId: c.req.param('uploadSessionId'), @@ -264,15 +245,14 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db - const matter = await getMatter(db, c.req.param('id'), orgId) + const matter = await c.get('deps').matter.get(c.req.param('id'), orgId) if (!matter) return c.json({ error: 'Not found' }, 404) if (matter.dirtype !== DirType.FILE || !matter.object) { return c.json(matter) } - const storage = await getStorage(db, matter.storageId) + const storage = await c.get('deps').storages.get(matter.storageId) if (!storage) return c.json({ error: 'Storage not found' }, 404) const trafficError = await consumeAndReportDownloadTraffic(c, { @@ -287,9 +267,9 @@ const app = new Hono() let downloadUrl: string try { - downloadUrl = await s3.presignDownload(storage, matter.object, matter.name) + downloadUrl = await c.get('deps').s3.presignDownload(storage, matter.object, matter.name) } catch (e) { - await refundTraffic(db, orgId, matter.size ?? 0) + await c.get('deps').quota.refundTraffic(orgId, matter.size ?? 0) throw e } @@ -299,17 +279,16 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db const userId = actorId(c) const body = c.req.valid('json') const principal = c.get('principal') if (principal?.kind === 'download-task-upload') { if (body.action !== 'confirm') return c.json({ error: 'Download task upload token can only confirm uploads' }, 403) - const matter = await getMatter(db, c.req.param('id'), orgId) + const matter = await c.get('deps').matter.get(c.req.param('id'), orgId) if (!matter || !isWithinDownloadTarget(matter.parent, principal.targetFolder)) return c.json({ error: 'Forbidden' }, 403) - await assertTaskUploadAllowed(c.get('platform'), { + await assertTaskUploadAllowed(c.get('deps'), { taskId: principal.taskId, downloaderId: principal.downloaderId, }) @@ -318,7 +297,7 @@ const app = new Hono() switch (body.action) { case 'update': { try { - const matter = await updateMatter(db, c.req.param('id'), orgId, body, userId) + const matter = await c.get('deps').matter.update(c.req.param('id'), orgId, body, userId) if (!matter) return c.json({ error: 'Not found' }, 404) return c.json(matter) } catch (e) { @@ -329,10 +308,10 @@ const app = new Hono() } case 'confirm': { try { - const { matter, quotaExceeded } = await confirmUpload(db, c.req.param('id'), orgId, { + const { matter, quotaExceeded } = await confirmUpload(c.get('deps'), c.req.param('id'), orgId, { onConflict: body.onConflict, userId, - purgeReplaced: (incumbent) => purgeRecursively(db, orgId, [incumbent]).then(() => undefined), + purgeReplaced: (incumbent) => purgeRecursively(c.get('deps'), orgId, [incumbent]).then(() => undefined), }) if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422) if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404) @@ -344,13 +323,13 @@ const app = new Hono() } } case 'cancel': { - const matter = await cancelDraftMatter(db, c.req.param('id'), orgId, userId) + const matter = await c.get('deps').matter.cancelDraft(c.req.param('id'), orgId, userId) if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404) if (matter.object) { - const storage = await getStorage(db, matter.storageId) + const storage = await c.get('deps').storages.get(matter.storageId) if (storage) { try { - await s3.deleteObject(storage, matter.object) + await c.get('deps').s3.deleteObject(storage, matter.object) } catch { // Best-effort cleanup: the browser may abort before S3 writes anything. } @@ -359,13 +338,13 @@ const app = new Hono() return c.json({ id: matter.id, cancelled: true }) } case 'trash': { - const matter = await trashMatter(db, orgId, c.req.param('id'), userId) + const matter = await c.get('deps').matter.trash(orgId, c.req.param('id'), userId) if (!matter) return c.json({ error: 'Not found' }, 404) return c.json(matter) } case 'restore': { try { - const matter = await restoreMatter(db, orgId, c.req.param('id'), userId, body.onConflict ?? 'fail') + const matter = await c.get('deps').matter.restore(orgId, c.req.param('id'), userId, body.onConflict ?? 'fail') if (!matter) return c.json({ error: 'Not found' }, 404) return c.json(matter) } catch (e) { @@ -380,14 +359,13 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) const userId = c.get('userId')! - const db = c.get('platform').db - const ms = await collectForPurge(db, orgId, c.req.param('id')) + const ms = await c.get('deps').matter.collectForPurge(orgId, c.req.param('id')) if (!ms) return c.json({ error: 'Not found' }, 404) if (ms[0].status !== 'trashed') { return c.json({ error: 'Object must be trashed before permanent deletion' }, 409) } - const purged = await purgeRecursively(db, orgId, ms) - await recordActivity(db, { + const purged = await purgeRecursively(c.get('deps'), orgId, ms) + await c.get('deps').activity.record({ orgId, userId, action: 'object_purge', @@ -402,19 +380,18 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db const userId = c.get('userId')! const { copyFrom, parent, onConflict } = c.req.valid('json') - const source = await getMatter(db, copyFrom, orgId) + const source = await c.get('deps').matter.get(copyFrom, orgId) if (!source) return c.json({ error: 'Not found' }, 404) const sourceSize = source.size ?? 0 - const storage = source.object ? await getStorage(db, source.storageId) : null + const storage = source.object ? await c.get('deps').storages.get(source.storageId) : null if (source.object && !storage) return c.json({ error: 'Storage not found' }, 404) try { const copy = await withStorageUsageReservation( - db, + c.get('deps'), { orgId, storageId: source.storageId, bytes: sourceSize }, async (ctx) => { let newObject = '' @@ -427,10 +404,10 @@ const app = new Hono() orgId, rawExt: fileExt(source.name), }) - await s3.copyObject(objectStorage, source.object, objectStorage, newObject) - ctx.onRollback(() => s3.deleteObject(objectStorage, newObject)) + await c.get('deps').s3.copyObject(objectStorage, source.object, objectStorage, newObject) + ctx.onRollback(() => c.get('deps').s3.deleteObject(objectStorage, newObject)) } - return copyMatter(db, source, parent, newObject, { onConflict, userId }) + return c.get('deps').matter.copy(source, parent, newObject, { onConflict, userId }) }, ) return c.json(copy, 201) @@ -444,12 +421,11 @@ const app = new Hono() const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db const userId = c.get('userId')! const { targetOrgId, targetParent, mode } = c.req.valid('json') if (targetOrgId === orgId) return c.json({ error: 'Target must be a different space', code: 'SAME_ORG' }, 400) - const source = await getMatter(db, c.req.param('id'), orgId) + const source = await c.get('deps').matter.get(c.req.param('id'), orgId) if (!source || source.status !== 'active') return c.json({ error: 'Not found' }, 404) // Copying out only needs read access on the source space (granted by the @@ -457,16 +433,16 @@ const app = new Hono() if (mode === 'move' && !(await hasEditorAccess(c))) { return c.json({ error: 'Forbidden' }, 403) } - if (!(await canWriteToOrg(db, userId, targetOrgId))) { + if (!(await c.get('deps').org.canWriteToOrg(userId, targetOrgId))) { return c.json({ error: 'Forbidden' }, 403) } - const totalBytes = await computeSourceBytes(db, source) - if (!(await isQuotaSufficient(db, targetOrgId, totalBytes))) { + const totalBytes = await c.get('deps').share.computeSourceBytes(source) + if (!(await c.get('deps').share.hasQuotaForBytes(targetOrgId, totalBytes))) { return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 422) } - const result = await copyMatterToOrg(db, { + const result = await copyMatterToOrg(c.get('deps'), { sourceMatter: source, currentUserId: userId, targetOrgId, @@ -484,10 +460,10 @@ const app = new Hono() // spaces. The independent copy already lives in the target space. let sourceDeleted = false if (mode === 'move' && result.skipped.length === 0) { - const subtree = await collectForPurge(db, orgId, source) - await purgeRecursively(db, orgId, subtree) + const subtree = await c.get('deps').matter.collectForPurge(orgId, source) + await purgeRecursively(c.get('deps'), orgId, subtree) sourceDeleted = true - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'moved_to_org', @@ -507,9 +483,9 @@ async function hasEditorAccess(c: Context): Promise { const orgId = c.get('orgId') const userId = c.get('userId') if (!orgId || !userId) return false - const role = await getMemberRole(c.get('platform').db, orgId, userId) + const role = await c.get('deps').org.getMemberRole(orgId, userId) if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.editor - return isPersonalOrg(c.get('platform').db, orgId) + return c.get('deps').org.isPersonalOrg(orgId) } function actorId(c: Context): string { diff --git a/server/routes/profile.integration.test.ts b/server/http/profile.integration.test.ts similarity index 85% rename from server/routes/profile.integration.test.ts rename to server/http/profile.integration.test.ts index 345810d9..0717a865 100644 --- a/server/routes/profile.integration.test.ts +++ b/server/http/profile.integration.test.ts @@ -1,6 +1,6 @@ import { sql } from 'drizzle-orm' import { describe, expect, it } from 'vitest' -import { buildBreadcrumb } from '../services/profile.js' +import { buildBreadcrumb } from '../domain/breadcrumb.js' import { createTestApp } from '../test/setup.js' async function insertUser( @@ -24,7 +24,7 @@ async function insertUser( } describe('GET /api/profiles/:username', () => { - it('returns 404 when user does not exist', async () => { + it('returns 404 when user does not exist [spec: profile/user-not-found]', async () => { const { app } = await createTestApp() const res = await app.request('/api/profiles/nonexistent') expect(res.status).toBe(404) @@ -32,7 +32,7 @@ describe('GET /api/profiles/:username', () => { expect(body).toEqual({ error: 'User not found' }) }) - it('returns user info and empty shares', async () => { + it('returns user info and empty shares [spec: profile/user-info]', async () => { const { app, db } = await createTestApp() await insertUser(db, { id: 'user-1', username: 'testuser', email: 'test@example.com' }) @@ -43,7 +43,7 @@ describe('GET /api/profiles/:username', () => { expect(body.shares).toEqual([]) }) - it('works without authentication', async () => { + it('works without authentication [spec: profile/public]', async () => { const { app, db } = await createTestApp() await insertUser(db, { id: 'user-1', username: 'testuser', email: 'test@example.com' }) @@ -51,7 +51,7 @@ describe('GET /api/profiles/:username', () => { expect(res.status).toBe(200) }) - it('returns user info when user exists but has no personal org', async () => { + it('returns user info when user exists but has no personal org [spec: profile/no-personal-org]', async () => { const { app, db } = await createTestApp() const now = Date.now() await db.run(sql` @@ -68,7 +68,7 @@ describe('GET /api/profiles/:username', () => { }) describe('GET /api/profiles/:username/browse', () => { - it('returns 404 for unknown username', async () => { + it('returns 404 for unknown username [spec: profile/unknown-username]', async () => { const { app } = await createTestApp() const res = await app.request('/api/profiles/nonexistent/browse') expect(res.status).toBe(404) @@ -76,7 +76,7 @@ describe('GET /api/profiles/:username/browse', () => { expect(body).toEqual({ error: 'User not found' }) }) - it('returns empty items and breadcrumb for known user', async () => { + it('returns empty items and breadcrumb for known user [spec: profile/empty-listing]', async () => { const { app, db } = await createTestApp() await insertUser(db, { id: 'user-1', username: 'testuser', email: 'test@example.com' }) @@ -97,7 +97,7 @@ describe('buildBreadcrumb', () => { expect(buildBreadcrumb('photos')).toEqual(['photos']) }) - it('splits nested path into segments', () => { + it('splits nested path into segments [spec: profile/breadcrumb-segments]', () => { expect(buildBreadcrumb('a/b/c')).toEqual(['a', 'b', 'c']) }) diff --git a/server/routes/profile.ts b/server/http/profile.ts similarity index 68% rename from server/routes/profile.ts rename to server/http/profile.ts index d0fade82..379be739 100644 --- a/server/routes/profile.ts +++ b/server/http/profile.ts @@ -1,19 +1,16 @@ import { Hono } from 'hono' import type { Env } from '../middleware/platform' -import { getUserByUsername } from '../services/profile' const app = new Hono() .get('/:username', async (c) => { - const db = c.get('platform').db const { username } = c.req.param() - const profileUser = await getUserByUsername(db, username) + const profileUser = await c.get('deps').profiles.getUserByUsername(username) if (!profileUser) return c.json({ error: 'User not found' }, 404) return c.json({ user: profileUser, shares: [] }) }) .get('/:username/browse', async (c) => { - const db = c.get('platform').db const { username } = c.req.param() - const profileUser = await getUserByUsername(db, username) + const profileUser = await c.get('deps').profiles.getUserByUsername(username) if (!profileUser) return c.json({ error: 'User not found' }, 404) return c.json({ items: [], breadcrumb: [] }) }) diff --git a/server/routes/quotas-listing.integration.test.ts b/server/http/quotas-listing.integration.test.ts similarity index 100% rename from server/routes/quotas-listing.integration.test.ts rename to server/http/quotas-listing.integration.test.ts diff --git a/server/routes/quotas.integration.test.ts b/server/http/quotas.integration.test.ts similarity index 93% rename from server/routes/quotas.integration.test.ts rename to server/http/quotas.integration.test.ts index 787e0e36..7461bc02 100644 --- a/server/routes/quotas.integration.test.ts +++ b/server/http/quotas.integration.test.ts @@ -15,13 +15,13 @@ async function adminHeaders(app: ReturnType { - it('returns 401 without auth', async () => { + it('returns 401 without auth [spec: quotas/admin-auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/admin/quotas') expect(res.status).toBe(401) }) - it('returns 403 for non-admin', async () => { + it('returns 403 for non-admin [spec: quotas/admin-only]', async () => { const { app } = await createTestApp() await authedHeaders(app, 'admin@example.com') await authedHeaders(app, 'regular@example.com') @@ -35,7 +35,7 @@ describe('Admin Quotas API', () => { expect(res.status).toBe(403) }) - it('GET /api/admin/quotas returns the default quota row created at signup', async () => { + it('GET /api/admin/quotas returns the default quota row created at signup [spec: quotas/default-row]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/quotas', { headers }) @@ -49,7 +49,7 @@ describe('Admin Quotas API', () => { expect(body.items[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/) }) - it('GET /api/admin/quotas normalizes stale monthly traffic period in the response without writing', async () => { + it('GET /api/admin/quotas normalizes stale monthly traffic period in the response without writing [spec: quotas/normalizes-stale-period]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await db.run(sql`UPDATE org_quotas SET traffic_quota = 1000, traffic_used = 900, traffic_period = '1970-01'`) @@ -69,7 +69,7 @@ describe('Admin Quotas API', () => { expect(rows[0].trafficPeriod).toBe('1970-01') }) - it('GET /api/admin/quotas lists quotas with org info', async () => { + it('GET /api/admin/quotas lists quotas with org info [spec: quotas/list-with-org]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -91,7 +91,7 @@ describe('Admin Quotas API', () => { expect(body.items[0].orgType).toBe('personal') }) - it('GET /api/admin/quotas lists effective quota with active entitlements', async () => { + it('GET /api/admin/quotas lists effective quota with active entitlements [spec: quotas/effective-with-entitlements]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) const orgs = await db.all<{ id: string }>( @@ -126,7 +126,7 @@ describe('Admin Quotas API', () => { }) }) - it('GET /api/admin/quotas exposes active plan and extra quota labels', async () => { + it('GET /api/admin/quotas exposes active plan and extra quota labels [spec: quotas/plan-labels]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) const orgs = await db.all<{ id: string }>( @@ -167,13 +167,13 @@ describe('Admin Quotas API', () => { }) describe('User Quotas API — /api/quotas', () => { - it('GET /api/quotas/me returns 401 without auth', async () => { + it('GET /api/quotas/me returns 401 without auth [spec: quotas/me-auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/quotas/me') expect(res.status).toBe(401) }) - it('GET /api/quotas/me returns the built-in default quota of 10MB when no system option is set', async () => { + it('GET /api/quotas/me returns the built-in default quota of 10MB when no system option is set [spec: quotas/me-default]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) const res = await app.request('/api/quotas/me', { headers }) @@ -187,7 +187,7 @@ describe('User Quotas API — /api/quotas', () => { expect(body.orgId).toBeTruthy() }) - it('GET /api/quotas/me returns 404 when user has no org', async () => { + it('GET /api/quotas/me returns 404 when user has no org [spec: quotas/me-no-org]', async () => { const { app, db } = await createTestApp() const _headers = await authedHeaders(app, 'noorg@example.com') // Delete the user's org membership and org to simulate no org @@ -204,7 +204,7 @@ describe('User Quotas API — /api/quotas', () => { expect(res.status).toBe(404) }) - it('GET /api/quotas/me returns base quota plus active entitlements and labels', async () => { + it('GET /api/quotas/me returns base quota plus active entitlements and labels [spec: quotas/me-effective]', async () => { const { app, db } = await createTestApp() const adminH = await adminHeaders(app) const orgs = await db.all<{ id: string }>( diff --git a/server/routes/quotas.ts b/server/http/quotas.ts similarity index 61% rename from server/routes/quotas.ts rename to server/http/quotas.ts index 2cc16ddd..995aca41 100644 --- a/server/routes/quotas.ts +++ b/server/http/quotas.ts @@ -1,31 +1,15 @@ -import { eq } from 'drizzle-orm' import { Hono } from 'hono' -import { organization } from '../db/auth-schema' -import { orgQuotas } from '../db/schema' import { requireAdmin, requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { getEffectiveQuota, getEffectiveQuotasByOrg } from '../services/effective-quota' -import { findPersonalOrg } from '../services/org' // Quota overview across all orgs (personal + team), used by the admin dashboard. // Per-team entitlement management lives under /api/admin/teams. const adminQuotas = new Hono().use(requireAdmin).get('/', async (c) => { - const db = c.get('platform').db const now = new Date() - const rows = await 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) + const rows = await c.get('deps').quota.listOrgQuotaOverview() - const quotas = await getEffectiveQuotasByOrg( - db, + const quotas = await c.get('deps').quota.getEffectiveQuotasByOrg( rows.map((r) => r.orgId), now, ) @@ -41,15 +25,14 @@ const adminQuotas = new Hono().use(requireAdmin).get('/', async (c) => { }) const userQuotas = new Hono().use(requireAuth).get('/me', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! - const orgId = c.get('orgId') ?? (await findPersonalOrg(db, userId)) + const orgId = c.get('orgId') ?? (await c.get('deps').org.findPersonalOrg(userId)) if (!orgId) { return c.json({ error: 'No organization found' }, 404) } - const quota = await getEffectiveQuota(db, orgId) + const quota = await c.get('deps').quota.getEffectiveQuota(orgId) return c.json(quota) }) diff --git a/server/routes/redirect.cf-test.ts b/server/http/redirect.cf-test.ts similarity index 96% rename from server/routes/redirect.cf-test.ts rename to server/http/redirect.cf-test.ts index 46999876..177bd923 100644 --- a/server/routes/redirect.cf-test.ts +++ b/server/http/redirect.cf-test.ts @@ -1,11 +1,11 @@ import { env } from 'cloudflare:workers' import { sql } from 'drizzle-orm' import { describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3' +import { createShareRepo } from '../adapters/repos/share' import { createApp } from '../app' import { createAuth } from '../auth' import { createCloudflarePlatform } from '../platform/cloudflare' -import { S3Service } from '../services/s3' -import { createShare } from '../services/share' const STORAGE_ID = 'st-cf-redirect' const MOCK_PRESIGN_URL = 'https://presigned-cf.example.com/file' @@ -97,7 +97,7 @@ describe('[CF] /r/:token ds_ direct shares', () => { const rows = await db.all<{ id: string }>(sql`SELECT id FROM matters WHERE name = 'cf-direct.bin' LIMIT 1`) const matterId = rows[0].id - const share = await createShare(db, { matterId, orgId, creatorId: userId, kind: 'direct' }) + const share = await createShareRepo(db).create({ matterId, orgId, creatorId: userId, kind: 'direct' }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(302) @@ -148,7 +148,7 @@ describe('[CF] Concurrent downloads — atomic limit enforcement via /r/', () => const fileId = `cf-dlc-r-${Date.now()}` await insertFile(db, orgId, { id: fileId, name: 'concurrent-r.bin' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: fileId, orgId, creatorId: userId, diff --git a/server/routes/redirect.integration.test.ts b/server/http/redirect.integration.test.ts similarity index 88% rename from server/routes/redirect.integration.test.ts rename to server/http/redirect.integration.test.ts index a7afdefd..8141abf0 100644 --- a/server/routes/redirect.integration.test.ts +++ b/server/http/redirect.integration.test.ts @@ -1,8 +1,8 @@ import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { currentTrafficPeriod } from '../services/effective-quota.js' -import { S3Service } from '../services/s3.js' -import { createShare } from '../services/share.js' +import { S3Service } from '../adapters/gateways/s3.js' +import { createShareRepo } from '../adapters/repos/share' +import { currentTrafficPeriod } from '../domain/quota.js' import { authedHeaders, createTestApp } from '../test/setup.js' const MOCK_PRESIGN_URL = 'https://presigned-download.example.com/file' @@ -104,14 +104,14 @@ async function getAccessCount(db: Awaited>['db' // ─── ds_ direct share tests ─────────────────────────────────────────────────── describe('GET /r/:token (ds_ direct shares)', () => { - it('returns 302 with attachment disposition and no-store cache for valid direct share', async () => { + it('returns 302 with attachment disposition and no-store cache for valid direct share [spec: redirect/direct-share]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'ds-f1', name: 'file.bin' }) - const share = await createShare(db, { matterId: 'ds-f1', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ matterId: 'ds-f1', orgId, creatorId, kind: 'direct' }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(302) @@ -119,27 +119,27 @@ describe('GET /r/:token (ds_ direct shares)', () => { expect(res.headers.get('cache-control')).toContain('no-store') }) - it('returns 404 for unknown ds_ token', async () => { + it('returns 404 for unknown ds_ token [spec: redirect/unknown-ds-token]', async () => { const { app } = await createTestApp() const res = await app.request('/r/ds_unknowntoken', { redirect: 'manual' }) expect(res.status).toBe(404) }) - it('returns 404 for landing share token at /r/', async () => { + it('returns 404 for landing share token at /r/ [spec: redirect/landing-token-rejected]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'ds-f2', name: 'landing.txt' }) - const share = await createShare(db, { matterId: 'ds-f2', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'ds-f2', orgId, creatorId, kind: 'landing' }) // Landing share token does not start with ds_ so falls through to 404 const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(404) }) - it('returns 422 when direct share traffic quota is exhausted', async () => { + it('returns 422 when direct share traffic quota is exhausted [spec: redirect/ds-quota-exhausted]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -153,7 +153,13 @@ describe('GET /r/:token (ds_ direct shares)', () => { WHERE org_id = ${orgId} `) await setTrafficPlanEntitlement(db, orgId, 512) - const share = await createShare(db, { matterId: 'ds-quota', orgId, creatorId, kind: 'direct', downloadLimit: 1 }) + const share = await createShareRepo(db).create({ + matterId: 'ds-quota', + orgId, + creatorId, + kind: 'direct', + downloadLimit: 1, + }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(422) @@ -164,7 +170,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { expect(shares[0].downloads).toBe(0) }) - it('consumes traffic quota on successful direct share redirect', async () => { + it('consumes traffic quota on successful direct share redirect [spec: redirect/ds-consumes-quota]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -177,7 +183,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod} WHERE org_id = ${orgId} `) - const share = await createShare(db, { matterId: 'ds-quota-ok', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ matterId: 'ds-quota-ok', orgId, creatorId, kind: 'direct' }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(302) @@ -188,7 +194,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { expect(rows[0].trafficUsed).toBe(1280) }) - it('refunds traffic and download count when direct share signing fails', async () => { + it('refunds traffic and download count when direct share signing fails [spec: redirect/ds-refund-on-failure]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -202,7 +208,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { WHERE org_id = ${orgId} `) vi.mocked(S3Service.prototype.presignDownload).mockRejectedValueOnce(new Error('sign failed')) - const share = await createShare(db, { matterId: 'ds-sign-fail', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ matterId: 'ds-sign-fail', orgId, creatorId, kind: 'direct' }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(500) @@ -220,7 +226,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { // ─── ih_ image hosting tests ────────────────────────────────────────────────── describe('GET /r/:token (ih_ image hosting)', () => { - it('returns 302 with inline disposition and no-store cache for active image', async () => { + it('returns 302 with inline disposition and no-store cache for active image [spec: redirect/image]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -234,7 +240,7 @@ describe('GET /r/:token (ih_ image hosting)', () => { expect(cc).toContain('no-store') }) - it('strips .png extension and resolves same image', async () => { + it('strips .png extension and resolves same image [spec: redirect/image-strip-ext]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -258,13 +264,13 @@ describe('GET /r/:token (ih_ image hosting)', () => { expect(res.headers.get('location')).toBe(MOCK_INLINE_URL) }) - it('returns 404 for non-existent ih_ token', async () => { + it('returns 404 for non-existent ih_ token [spec: redirect/unknown-ih-token]', async () => { const { app } = await createTestApp() const res = await app.request('/r/ih_doesnotexist', { redirect: 'manual' }) expect(res.status).toBe(404) }) - it('returns 404 for image with status=draft', async () => { + it('returns 404 for image with status=draft [spec: redirect/image-draft-hidden]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -275,7 +281,7 @@ describe('GET /r/:token (ih_ image hosting)', () => { expect(res.status).toBe(404) }) - it('increments accessCount by 1 on successful redirect', async () => { + it('increments accessCount by 1 on successful redirect [spec: redirect/image-access-count]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -287,7 +293,7 @@ describe('GET /r/:token (ih_ image hosting)', () => { expect(await getAccessCount(db, 'ih-cnt1')).toBe(1) }) - it('consumes traffic quota on successful image hosting redirect', async () => { + it('consumes traffic quota on successful image hosting redirect [spec: redirect/image-consumes-quota]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -309,7 +315,7 @@ describe('GET /r/:token (ih_ image hosting)', () => { expect(rows[0].trafficUsed).toBe(1280) }) - it('refunds traffic when image hosting signing fails', async () => { + it('refunds traffic when image hosting signing fails [spec: redirect/image-refund-on-failure]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -333,7 +339,7 @@ describe('GET /r/:token (ih_ image hosting)', () => { expect(await getAccessCount(db, 'ih-sign-fail')).toBe(0) }) - it('rejects the next image redirect after the first one consumes the remaining monthly traffic quota', async () => { + it('rejects the next image redirect after the first one consumes the remaining monthly traffic quota [spec: redirect/image-quota-boundary]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -358,7 +364,7 @@ describe('GET /r/:token (ih_ image hosting)', () => { expect(await getAccessCount(db, 'ih-quota-repeat')).toBe(1) }) - it('does NOT increment accessCount on 404', async () => { + it('does NOT increment accessCount on 404 [spec: redirect/no-count-on-404]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -373,7 +379,7 @@ describe('GET /r/:token (ih_ image hosting)', () => { // ─── Referer allowlist tests ────────────────────────────────────────────────── describe('GET /r/:token — referer allowlist enforcement', () => { - it('allows any referer when allowlist is empty', async () => { + it('allows any referer when allowlist is empty [spec: redirect/referer-empty-allowlist]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -388,7 +394,7 @@ describe('GET /r/:token — referer allowlist enforcement', () => { expect(res.status).toBe(302) }) - it('returns 302 when referer matches allowlist entry', async () => { + it('returns 302 when referer matches allowlist entry [spec: redirect/referer-match]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -403,7 +409,7 @@ describe('GET /r/:token — referer allowlist enforcement', () => { expect(res.status).toBe(302) }) - it('allows access when referer is missing (direct access from tools/address bar)', async () => { + it('allows access when referer is missing (direct access from tools/address bar) [spec: redirect/referer-missing-ok]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -415,7 +421,7 @@ describe('GET /r/:token — referer allowlist enforcement', () => { expect(res.status).toBe(302) }) - it('returns 403 when referer is from a different origin', async () => { + it('returns 403 when referer is from a different origin [spec: redirect/referer-mismatch]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -430,7 +436,7 @@ describe('GET /r/:token — referer allowlist enforcement', () => { expect(res.status).toBe(403) }) - it('returns 403 for subdomain mismatch (exact origin match required)', async () => { + it('returns 403 for subdomain mismatch (exact origin match required) [spec: redirect/referer-subdomain]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) @@ -445,7 +451,7 @@ describe('GET /r/:token — referer allowlist enforcement', () => { expect(res.status).toBe(403) }) - it('does NOT increment accessCount on 403', async () => { + it('does NOT increment accessCount on 403 [spec: redirect/no-count-on-403]', async () => { const { app, db } = await createTestApp() await authedHeaders(app) await insertStorage(db) diff --git a/server/routes/redirect.ts b/server/http/redirect.ts similarity index 65% rename from server/routes/redirect.ts rename to server/http/redirect.ts index 762806bc..3afccd5c 100644 --- a/server/routes/redirect.ts +++ b/server/http/redirect.ts @@ -1,17 +1,7 @@ import type { Context } from 'hono' import { Hono } from 'hono' import type { Env } from '../middleware/platform' -import type { Database } from '../platform/interface' -import { consumeTrafficIfQuotaAllows, refundTraffic } from '../services/effective-quota' -import { incrementAccessCount, resolveActiveImageByToken } from '../services/image-hosting' -import { - decrementDownloads, - hasDownloadsAvailable, - incrementDownloadsAtomic, - resolveShareByToken, -} from '../services/share' -import { getStorage } from '../services/storage' -import { PRESIGN_TTL_SECS, s3 } from './share-utils' +import { PRESIGN_TTL_SECS } from './share-utils' import { consumeAndReportDownloadTraffic, reportTrafficForDownload } from './traffic-metering-utils' // Strip optional file extension from token (e.g. "ih_aB3xK9.png" → "ih_aB3xK9") @@ -34,8 +24,8 @@ function checkReferer(refererAllowlist: string[], refererHeader: string | null): } } -async function handleDirectShare(c: Context, db: Database, token: string): Promise { - const resolved = await resolveShareByToken(db, token) +async function handleDirectShare(c: Context, token: string): Promise { + const resolved = await c.get('deps').share.resolveByToken(token) if (resolved.status !== 'ok') { if (resolved.status === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410) return c.json({ error: 'Share not found or revoked' }, 404) @@ -46,12 +36,13 @@ async function handleDirectShare(c: Context, db: Database, token: string): if (share.expiresAt && share.expiresAt < new Date()) return c.json({ error: 'Share has expired' }, 410) - if (!(await hasDownloadsAvailable(db, share.id))) return c.json({ error: 'Download limit exceeded' }, 410) + if (!(await c.get('deps').share.hasDownloadsAvailable(share.id))) + return c.json({ error: 'Download limit exceeded' }, 410) - const storage = await getStorage(db, matter.storageId) + const storage = await c.get('deps').storages.get(matter.storageId) if (!storage) return c.json({ error: 'Storage not found' }, 404) - const { ok } = await incrementDownloadsAtomic(db, share.id) + const { ok } = await c.get('deps').share.incrementDownloadsAtomic(share.id) if (!ok) return c.json({ error: 'Download limit exceeded' }, 410) const trafficError = await consumeAndReportDownloadTraffic(c, { @@ -61,16 +52,16 @@ async function handleDirectShare(c: Context, db: Database, token: string): source: 'direct_share', sourceId: share.id, quotaExceeded: () => c.json({ error: 'Traffic quota exceeded' }, 422), - onRejected: () => decrementDownloads(db, share.id), + onRejected: () => c.get('deps').share.decrementDownloads(share.id), }) if (trafficError) return trafficError let url: string try { - url = await s3.presignDownload(storage, matter.object, matter.name, PRESIGN_TTL_SECS) + url = await c.get('deps').s3.presignDownload(storage, matter.object, matter.name, PRESIGN_TTL_SECS) } catch (e) { - await refundTraffic(db, share.orgId, matter.size ?? 0) - await decrementDownloads(db, share.id) + await c.get('deps').quota.refundTraffic(share.orgId, matter.size ?? 0) + await c.get('deps').share.decrementDownloads(share.id) throw e } @@ -79,8 +70,8 @@ async function handleDirectShare(c: Context, db: Database, token: string): return res } -async function handleImageHosting(c: Context, db: Database, token: string): Promise { - const resolved = await resolveActiveImageByToken(db, token) +async function handleImageHosting(c: Context, token: string): Promise { + const resolved = await c.get('deps').imageHosting.resolveActiveByToken(token) if (!resolved) return c.json({ error: 'Not found' }, 404) const { image, refererAllowlist } = resolved @@ -94,17 +85,17 @@ async function handleImageHosting(c: Context, db: Database, token: string): return c.json({ error: 'forbidden referer' }, 403) } - const storage = await getStorage(db, image.storageId) + const storage = await c.get('deps').storages.get(image.storageId) if (!storage) return c.json({ error: 'Storage not found' }, 404) - const trafficAllowed = await consumeTrafficIfQuotaAllows(db, image.orgId, image.size) + const trafficAllowed = await c.get('deps').quota.consumeTrafficIfQuotaAllows(image.orgId, image.size) if (!trafficAllowed) return c.json({ error: 'Traffic quota exceeded' }, 422) let url: string try { - url = await s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS) + url = await c.get('deps').s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS) } catch (e) { - await refundTraffic(db, image.orgId, image.size) + await c.get('deps').quota.refundTraffic(image.orgId, image.size) throw e } @@ -118,7 +109,7 @@ async function handleImageHosting(c: Context, db: Database, token: string): if (trafficReportError) return trafficReportError try { - await incrementAccessCount(db, image.id) + await c.get('deps').imageHosting.incrementAccessCount(image.id) } catch (error) { console.error('[redirect] incrementAccessCount failed:', error) } @@ -130,10 +121,9 @@ async function handleImageHosting(c: Context, db: Database, token: string): const app = new Hono().get('/:token', async (c) => { const raw = c.req.param('token') const token = stripExtension(raw) - const db = c.get('platform').db - if (token.startsWith('ds_')) return handleDirectShare(c, db, token) - if (token.startsWith('ih_')) return handleImageHosting(c, db, token) + if (token.startsWith('ds_')) return handleDirectShare(c, token) + if (token.startsWith('ih_')) return handleImageHosting(c, token) return c.json({ error: 'Not found' }, 404) }) diff --git a/server/routes/share-public.cf-test.ts b/server/http/share-public.cf-test.ts similarity index 96% rename from server/routes/share-public.cf-test.ts rename to server/http/share-public.cf-test.ts index 859a10a7..e607565a 100644 --- a/server/routes/share-public.cf-test.ts +++ b/server/http/share-public.cf-test.ts @@ -1,10 +1,10 @@ import { env } from 'cloudflare:workers' import { sql } from 'drizzle-orm' import { describe, expect, it } from 'vitest' +import { createShareRepo } from '../adapters/repos/share' import { createApp } from '../app' import { createAuth } from '../auth' import { createCloudflarePlatform } from '../platform/cloudflare' -import { createShare } from '../services/share' // ─── CF routing regression guard ───────────────────────────────────────────── // Verifies that public share JSON endpoints live under /api/* (Worker-handled) @@ -90,7 +90,7 @@ describe('[CF] Public share routes — no requireAuth', () => { const rows = await db.all<{ id: string }>(sql`SELECT id FROM matters WHERE name = 'cf-file.txt' LIMIT 1`) const matterId = rows[0].id - const share = await createShare(db, { matterId, orgId, creatorId: userId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId, orgId, creatorId: userId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}`) expect(res.status).toBe(200) @@ -116,7 +116,7 @@ describe('[CF] Concurrent downloads — atomic limit enforcement', () => { await insertFile(db, orgId, { id: fileId, name: 'concurrent.bin' }) // Spy on presignDownload to return a fake URL without hitting real S3 - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: fileId, orgId, creatorId: userId, diff --git a/server/routes/share-public.integration.test.ts b/server/http/share-public.integration.test.ts similarity index 90% rename from server/routes/share-public.integration.test.ts rename to server/http/share-public.integration.test.ts index 9a836c3b..7528365c 100644 --- a/server/routes/share-public.integration.test.ts +++ b/server/http/share-public.integration.test.ts @@ -1,8 +1,8 @@ import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { currentTrafficPeriod } from '../services/effective-quota.js' -import { S3Service } from '../services/s3.js' -import { createShare } from '../services/share.js' +import { S3Service } from '../adapters/gateways/s3.js' +import { createShareRepo } from '../adapters/repos/share' +import { currentTrafficPeriod } from '../domain/quota.js' import { authedHeaders, createTestApp } from '../test/setup.js' const MOCK_PRESIGN_URL = 'https://presigned-download.example.com/file' @@ -103,7 +103,7 @@ describe('GET /api/shares/:token', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'f1', name: 'photo.jpg' }) - const share = await createShare(db, { matterId: 'f1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'f1', orgId, creatorId, kind: 'landing' }) // Call without auth headers to simulate a public visitor void headers @@ -134,7 +134,7 @@ describe('GET /api/shares/:token', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'f1v', name: 'visit-count.txt' }) - const share = await createShare(db, { matterId: 'f1v', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'f1v', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}`) expect(res.status).toBe(200) @@ -150,7 +150,7 @@ describe('GET /api/shares/:token', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'f1vd', name: 'visit-dedupe.txt' }) - const share = await createShare(db, { matterId: 'f1vd', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'f1vd', orgId, creatorId, kind: 'landing' }) const first = await app.request(`/api/shares/${share.token}`) expect(first.status).toBe(200) @@ -173,7 +173,7 @@ describe('GET /api/shares/:token', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'f2', name: 'direct.txt' }) - const share = await createShare(db, { matterId: 'f2', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ matterId: 'f2', orgId, creatorId, kind: 'direct' }) const res = await app.request(`/api/shares/${share.token}`) expect(res.status).toBe(404) @@ -203,7 +203,7 @@ describe('GET /api/shares/:token', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'f4', name: 'revoked.txt' }) - const share = await createShare(db, { matterId: 'f4', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'f4', orgId, creatorId, kind: 'landing' }) await db.run(sql`UPDATE shares SET status = 'revoked' WHERE id = ${share.id}`) const res = await app.request(`/api/shares/${share.token}`) @@ -217,7 +217,7 @@ describe('GET /api/shares/:token', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'f5', name: 'secret.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'f5', orgId, creatorId, @@ -238,7 +238,7 @@ describe('GET /api/shares/:token', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'f6', name: 'limited.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'f6', orgId, creatorId, @@ -264,7 +264,7 @@ describe('POST /api/shares/:token/sessions', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'vf1', name: 'vault.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'vf1', orgId, creatorId, @@ -291,7 +291,7 @@ describe('POST /api/shares/:token/sessions', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'vf2', name: 'vault2.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'vf2', orgId, creatorId, @@ -324,7 +324,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod} WHERE org_id = ${orgId} `) - const share = await createShare(db, { matterId: 'dl1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'dl1', orgId, creatorId, kind: 'landing' }) const rootRef = await fetchRootRef(app, share.token) const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' }) @@ -345,7 +345,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dl-url1', name: 'report.docx' }) - const share = await createShare(db, { matterId: 'dl-url1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'dl-url1', orgId, creatorId, kind: 'landing' }) const rootRef = await fetchRootRef(app, share.token) const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, { redirect: 'manual' }) @@ -368,7 +368,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod} WHERE org_id = ${orgId} `) - const share = await createShare(db, { matterId: 'dl-traffic-ok', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'dl-traffic-ok', orgId, creatorId, kind: 'landing' }) const rootRef = await fetchRootRef(app, share.token) const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, { redirect: 'manual' }) @@ -394,7 +394,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { WHERE org_id = ${orgId} `) vi.mocked(S3Service.prototype.presignDownload).mockRejectedValueOnce(new Error('sign failed')) - const share = await createShare(db, { matterId: 'dl-sign-fail', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'dl-sign-fail', orgId, creatorId, kind: 'landing' }) const rootRef = await fetchRootRef(app, share.token) const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, { redirect: 'manual' }) @@ -423,7 +423,13 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { WHERE org_id = ${orgId} `) await setTrafficPlanEntitlement(db, orgId, 512) - const share = await createShare(db, { matterId: 'dl-traffic', orgId, creatorId, kind: 'landing', downloadLimit: 1 }) + const share = await createShareRepo(db).create({ + matterId: 'dl-traffic', + orgId, + creatorId, + kind: 'landing', + downloadLimit: 1, + }) const rootRef = await fetchRootRef(app, share.token) const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, { redirect: 'manual' }) @@ -443,7 +449,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dl2', name: 'secret.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'dl2', orgId, creatorId, @@ -463,7 +469,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dl3', name: 'guarded.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'dl3', orgId, creatorId, @@ -486,7 +492,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { const orgId = await getOrgId(db) const userId = await getUserId(db) await insertFile(db, orgId, { id: 'dl4', name: 'recipient.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'dl4', orgId, creatorId: userId, @@ -510,7 +516,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dl5', name: 'limited.txt' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'dl5', orgId, creatorId, @@ -535,7 +541,7 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dl6', name: 'expired.txt' }) const pastDate = new Date(Date.now() - 1000) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'dl6', orgId, creatorId, @@ -581,7 +587,7 @@ describe('GET /api/shares/:token/objects', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'ch1', name: 'flat.txt' }) - const share = await createShare(db, { matterId: 'ch1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'ch1', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}/objects`) expect(res.status).toBe(400) @@ -596,7 +602,7 @@ describe('GET /api/shares/:token/objects', () => { await insertFolder(db, orgId, { id: 'dir1', name: 'Photos' }) await insertFile(db, orgId, { id: 'img1', name: 'cat.jpg', parent: 'Photos' }) await insertFolder(db, orgId, { id: 'dir2', name: 'vacation', parent: 'Photos' }) - const share = await createShare(db, { matterId: 'dir1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'dir1', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}/objects`) expect(res.status).toBe(200) @@ -624,7 +630,7 @@ describe('GET /api/shares/:token/objects', () => { await insertFolder(db, orgId, { id: 'root1', name: 'Docs' }) await insertFolder(db, orgId, { id: 'sub1', name: 'Reports', parent: 'Docs' }) await insertFile(db, orgId, { id: 'rpt1', name: 'q1.pdf', parent: 'Docs/Reports' }) - const share = await createShare(db, { matterId: 'root1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'root1', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}/objects?parent=Reports`) expect(res.status).toBe(200) @@ -647,7 +653,7 @@ describe('GET /api/shares/:token/objects', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFolder(db, orgId, { id: 'locked1', name: 'Private' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'locked1', orgId, creatorId, @@ -666,7 +672,7 @@ describe('GET /api/shares/:token/objects', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFolder(db, orgId, { id: 'traversal-dir', name: 'Safe' }) - const share = await createShare(db, { matterId: 'traversal-dir', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'traversal-dir', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}/objects?parent=../etc`) expect(res.status).toBe(400) @@ -681,7 +687,7 @@ describe('GET /api/shares/:token/objects', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFolder(db, orgId, { id: 'pg-dir', name: 'Paged' }) - const share = await createShare(db, { matterId: 'pg-dir', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'pg-dir', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}/objects?page=2&pageSize=10`) expect(res.status).toBe(200) @@ -697,7 +703,7 @@ describe('GET /api/shares/:token/objects', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFolder(db, orgId, { id: 'nan-dir', name: 'NaN' }) - const share = await createShare(db, { matterId: 'nan-dir', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'nan-dir', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}/objects?page=abc&pageSize=xyz`) expect(res.status).toBe(200) @@ -718,7 +724,7 @@ describe('GET /api/shares/:token/objects/:ref — descendant', () => { const creatorId = await getUserId(db) await insertFolder(db, orgId, { id: 'fld1', name: 'Archive' }) await insertFile(db, orgId, { id: 'arc1', name: 'old.zip', parent: 'Archive' }) - const share = await createShare(db, { matterId: 'fld1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'fld1', orgId, creatorId, kind: 'landing' }) const childrenRes = await app.request(`/api/shares/${share.token}/objects`) const childrenBody = (await childrenRes.json()) as { items: Array<{ ref: string }> } @@ -737,7 +743,7 @@ describe('GET /api/shares/:token/objects/:ref — descendant', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFolder(db, orgId, { id: 'fld2', name: 'Safe' }) - const share = await createShare(db, { matterId: 'fld2', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'fld2', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}/objects/invalid-ref`, { redirect: 'manual' }) expect(res.status).toBe(400) @@ -751,7 +757,7 @@ describe('GET /api/shares/:token/objects/:ref — descendant', () => { const creatorId = await getUserId(db) await insertFolder(db, orgId, { id: 'fld3', name: 'Folder3' }) await insertFile(db, orgId, { id: 'out1', name: 'outside.txt' }) - const share = await createShare(db, { matterId: 'fld3', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'fld3', orgId, creatorId, kind: 'landing' }) const { createHmac } = await import('node:crypto') const sig = createHmac('sha256', share.token).update('out1').digest('hex').slice(0, 16) @@ -772,7 +778,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dlx1', name: 'direct.bin' }) - const share = await createShare(db, { matterId: 'dlx1', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ matterId: 'dlx1', orgId, creatorId, kind: 'direct' }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(302) @@ -787,7 +793,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dlx2', name: 'landing.txt' }) - const share = await createShare(db, { matterId: 'dlx2', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'dlx2', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(404) @@ -823,7 +829,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dlx4', name: 'limited.bin' }) - const share = await createShare(db, { + const share = await createShareRepo(db).create({ matterId: 'dlx4', orgId, creatorId, @@ -843,7 +849,7 @@ describe('GET /r/:token (ds_ direct shares)', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'dlx5', name: 'public.bin' }) - const share = await createShare(db, { matterId: 'dlx5', orgId, creatorId, kind: 'direct' }) + const share = await createShareRepo(db).create({ matterId: 'dlx5', orgId, creatorId, kind: 'direct' }) const res = await app.request(`/r/${share.token}`, { redirect: 'manual' }) expect(res.status).toBe(302) @@ -860,7 +866,7 @@ describe('public routes require no auth', () => { const orgId = await getOrgId(db) const creatorId = await getUserId(db) await insertFile(db, orgId, { id: 'pub1', name: 'open.txt' }) - const share = await createShare(db, { matterId: 'pub1', orgId, creatorId, kind: 'landing' }) + const share = await createShareRepo(db).create({ matterId: 'pub1', orgId, creatorId, kind: 'landing' }) const res = await app.request(`/api/shares/${share.token}`) expect(res.status).toBe(200) diff --git a/server/routes/share-utils.ts b/server/http/share-utils.ts similarity index 92% rename from server/routes/share-utils.ts rename to server/http/share-utils.ts index 0157392d..01b1f328 100644 --- a/server/routes/share-utils.ts +++ b/server/http/share-utils.ts @@ -1,10 +1,9 @@ import { createHmac } from 'node:crypto' import type { Context } from 'hono' +import { isAccessibleByUser } from '../domain/share' import type { Env } from '../middleware/platform' -import { S3Service } from '../services/s3' -import { isAccessibleByUser, type ShareRecipient } from '../services/share' +import type { ShareRecipientRecord } from '../usecases/ports' -export const s3 = new S3Service() export const PRESIGN_TTL_SECS = 5 * 60 export function cookieName(token: string): string { @@ -63,7 +62,7 @@ export async function readUserId(c: Context): Promise { export function checkAccessGate( passwordHash: string | null, - recipients: ShareRecipient[], + recipients: ShareRecipientRecord[], userId: string | null, cookieValue: string | undefined, ): 'ok' | 'password_required' { diff --git a/server/routes/shares.integration.test.ts b/server/http/shares.integration.test.ts similarity index 92% rename from server/routes/shares.integration.test.ts rename to server/http/shares.integration.test.ts index 4aa634a5..28941468 100644 --- a/server/routes/shares.integration.test.ts +++ b/server/http/shares.integration.test.ts @@ -1,9 +1,8 @@ import { eq, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3.js' import { shareRecipients, shares } from '../db/schema.js' -import * as emailService from '../services/email.js' -import { S3Service } from '../services/s3.js' import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js' type TestApp = Awaited>['app'] @@ -92,7 +91,7 @@ async function getShareIdByToken(db: TestDb, token: string): Promise { // ─── POST /api/shares auth guard ───────────────────────────────────────────── describe('POST /api/shares (auth guard)', () => { - it('returns 401 without auth', async () => { + it('returns 401 without auth [spec: shares/auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/shares', { method: 'POST', @@ -108,10 +107,9 @@ describe('POST /api/shares (auth guard)', () => { describe('POST /api/shares', () => { beforeEach(() => { vi.restoreAllMocks() - vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) }) - it('creates a landing share without password and returns 201 with correct shape', async () => { + it('creates a landing share without password and returns 201 with correct shape [spec: shares/create-landing]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -130,7 +128,7 @@ describe('POST /api/shares', () => { expect(body.id).toBeUndefined() }) - it('creates a landing share with password and stores passwordHash in DB', async () => { + it('creates a landing share with password and stores passwordHash in DB [spec: shares/create-password]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -148,7 +146,7 @@ describe('POST /api/shares', () => { expect(rows[0]?.passwordHash).not.toBe('') }) - it('creates a landing share with recipients and inserts share_recipients rows', async () => { + it('creates a landing share with recipients and inserts share_recipients rows [spec: shares/create-recipients]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -167,7 +165,7 @@ describe('POST /api/shares', () => { expect(rows).toHaveLength(2) }) - it('creates a direct share for a file and returns direct url', async () => { + it('creates a direct share for a file and returns direct url [spec: shares/create-direct]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -183,7 +181,7 @@ describe('POST /api/shares', () => { expect((body.urls as Record).landing).toBeUndefined() }) - it('returns 400 with DIRECT_NO_FOLDER when creating direct share for a folder', async () => { + it('returns 400 with DIRECT_NO_FOLDER when creating direct share for a folder [spec: shares/direct-no-folder]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -197,7 +195,7 @@ describe('POST /api/shares', () => { expect(body.code).toBe('DIRECT_NO_FOLDER') }) - it('returns 400 with DIRECT_NO_PASSWORD when creating direct share with password', async () => { + it('returns 400 with DIRECT_NO_PASSWORD when creating direct share with password [spec: shares/direct-no-password]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -211,7 +209,7 @@ describe('POST /api/shares', () => { expect(body.code).toBe('DIRECT_NO_PASSWORD') }) - it('returns 404 when matterId does not belong to current org', async () => { + it('returns 404 when matterId does not belong to current org [spec: shares/create-cross-org]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) @@ -222,7 +220,7 @@ describe('POST /api/shares', () => { expect(body.code).toBe('MATTER_NOT_FOUND') }) - it('sets expiresAt when provided in request', async () => { + it('sets expiresAt when provided in request [spec: shares/create-expiry]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -241,7 +239,7 @@ describe('POST /api/shares', () => { expect(body.expiresAt).not.toBeNull() }) - it('sets downloadLimit when provided in request', async () => { + it('sets downloadLimit when provided in request [spec: shares/create-download-limit]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -259,7 +257,7 @@ describe('POST /api/shares', () => { expect(body.downloadLimit).toBe(5) }) - it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients', async () => { + it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients [spec: shares/direct-no-recipients]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) const orgId = await getOrgId(db) @@ -277,27 +275,26 @@ describe('POST /api/shares', () => { }) it('returns 500 when createShare throws an unexpected error', async () => { - const { app, db } = await createTestApp() + const { app, db, deps } = await createTestApp() const headers = await authedHeaders(app) const orgId = await getOrgId(db) const matterId = nanoid() await insertFile(db, orgId, { id: matterId, name: 'file.txt' }) - const shareService = await import('../services/share.js') - vi.spyOn(shareService, 'createShare').mockRejectedValueOnce(new Error('unexpected db error')) + vi.spyOn(deps.share, 'create').mockRejectedValueOnce(new Error('unexpected db error')) const res = await createShare(app, headers, { matterId, kind: 'landing' }) expect(res.status).toBe(500) }) - it('returns 201 even when dispatchShareCreated rejects', async () => { + it('returns 201 even when dispatchShareCreated rejects [spec: shares/create-notify-best-effort]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) const orgId = await getOrgId(db) const matterId = nanoid() await insertFile(db, orgId, { id: matterId, name: 'file.txt' }) - const notifService = await import('../services/share-notification.js') + const notifService = await import('../usecases/share-notification.js') vi.spyOn(notifService, 'dispatchShareCreated').mockRejectedValueOnce(new Error('dispatch failed')) const res = await createShare(app, headers, { @@ -324,10 +321,9 @@ describe('GET /api/shares (auth guard)', () => { describe('GET /api/shares', () => { beforeEach(() => { vi.restoreAllMocks() - vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) }) - it('returns empty list for a new user with no shares', async () => { + it('returns empty list for a new user with no shares [spec: shares/list-empty]', async () => { const { app } = await createTestApp() const headers = await authedHeaders(app) @@ -339,7 +335,7 @@ describe('GET /api/shares', () => { expect(body.total).toBe(0) }) - it('returns shares with pagination fields in response', async () => { + it('returns shares with pagination fields in response [spec: shares/list-pagination]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -360,7 +356,7 @@ describe('GET /api/shares', () => { expect(body.total).toBe(2) }) - it('does not return shares belonging to another user', async () => { + it('does not return shares belonging to another user [spec: shares/list-isolation]', async () => { const { app, db } = await createTestApp() await insertStorage(db) @@ -407,7 +403,7 @@ describe('GET /api/shares', () => { expect(item.recipientCount).toBe(1) }) - it('filters shares by status=active', async () => { + it('filters shares by status=active [spec: shares/list-filter-status]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -437,10 +433,9 @@ describe('GET /api/shares', () => { describe('GET /api/shares/:token', () => { beforeEach(() => { vi.restoreAllMocks() - vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) }) - it('returns full detail including recipients when viewer is the creator', async () => { + it('returns full detail including recipients when viewer is the creator [spec: shares/detail-creator]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -468,7 +463,7 @@ describe('GET /api/shares/:token', () => { expect(typeof body.rootRef).toBe('string') }) - it('returns landing view without recipients or internal ids for non-creator', async () => { + it('returns landing view without recipients or internal ids for non-creator [spec: shares/detail-non-creator]', async () => { const { app, db } = await createTestApp() await insertStorage(db) @@ -492,7 +487,7 @@ describe('GET /api/shares/:token', () => { expect(body.creatorId).toBeUndefined() }) - it('returns 404 for a non-existent token', async () => { + it('returns 404 for a non-existent token [spec: shares/detail-not-found]', async () => { const { app } = await createTestApp() const res = await app.request('/api/shares/does-not-exist') expect(res.status).toBe(404) @@ -534,7 +529,7 @@ describe('GET /api/shares/:token', () => { expect(Array.isArray(body.recipients)).toBe(true) }) - it('does not increment views when viewer is the creator', async () => { + it('does not increment views when viewer is the creator [spec: shares/no-self-view-count]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -557,7 +552,6 @@ describe('GET /api/shares/:token', () => { describe('POST /api/shares/:token/objects', () => { beforeEach(() => { vi.restoreAllMocks() - vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined) vi.spyOn(S3Service.prototype, 'streamCopy').mockResolvedValue(undefined) }) @@ -575,7 +569,7 @@ describe('POST /api/shares/:token/objects', () => { expect(res.status).toBe(404) }) - it('returns 400 with DIRECT_SAVE_FORBIDDEN for direct shares', async () => { + it('returns 400 with DIRECT_SAVE_FORBIDDEN for direct shares [spec: shares/save-direct-forbidden]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -596,7 +590,7 @@ describe('POST /api/shares/:token/objects', () => { expect(body.code).toBe('DIRECT_SAVE_FORBIDDEN') }) - it('returns 410 when the shared matter has been trashed', async () => { + it('returns 410 when the shared matter has been trashed [spec: shares/save-trashed-gone]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -616,7 +610,7 @@ describe('POST /api/shares/:token/objects', () => { expect(res.status).toBe(410) }) - it('saves a landing share file to personal drive and returns 201', async () => { + it('saves a landing share file to personal drive and returns 201 [spec: shares/save-to-drive]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -644,7 +638,7 @@ describe('POST /api/shares/:token/objects', () => { expect(res.status).toBe(401) }) - it('returns 400 QUOTA_EXCEEDED when target org quota is exhausted', async () => { + it('returns 400 QUOTA_EXCEEDED when target org quota is exhausted [spec: shares/save-quota-exceeded]', async () => { const { app, db } = await createTestApp() await seedProLicense(db) const headers = await authedHeaders(app) @@ -669,7 +663,7 @@ describe('POST /api/shares/:token/objects', () => { expect(body.code).toBe('QUOTA_EXCEEDED') }) - it('returns 403 when targetOrgId is not a personal org and user has no member role', async () => { + it('returns 403 when targetOrgId is not a personal org and user has no member role [spec: shares/save-target-permission]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -717,7 +711,7 @@ describe('POST /api/shares/:token/objects', () => { expect(res.status).toBe(403) }) - it('allows password-protected share save when the user is a listed recipient', async () => { + it('allows password-protected share save when the user is a listed recipient [spec: shares/save-recipient-bypass]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -776,7 +770,7 @@ describe('POST /api/shares/:token/objects', () => { expect(res.status).toBe(401) }) - it('bypasses 401 for password-protected share when non-recipient has valid sharetk cookie', async () => { + it('bypasses 401 for password-protected share when non-recipient has valid sharetk cookie [spec: shares/save-cookie-bypass]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -824,7 +818,7 @@ describe('POST /api/shares/:token/objects', () => { expect(res.status).toBe(401) }) - it('returns 403 when user has a viewer role in the target org', async () => { + it('returns 403 when user has a viewer role in the target org [spec: shares/save-viewer-forbidden]', async () => { const { app, db } = await createTestApp() const ownerHeaders = await authedHeaders(app) await insertStorage(db) @@ -876,10 +870,9 @@ describe('DELETE /api/shares/:token (auth guard)', () => { describe('DELETE /api/shares/:token', () => { beforeEach(() => { vi.restoreAllMocks() - vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) }) - it('creator can delete their share and share status becomes revoked in DB', async () => { + it('creator can delete their share and share status becomes revoked in DB [spec: shares/delete]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) await insertStorage(db) @@ -896,7 +889,7 @@ describe('DELETE /api/shares/:token', () => { expect(rows[0]?.status).toBe('revoked') }) - it('returns 403 when non-creator tries to delete a share', async () => { + it('returns 403 when non-creator tries to delete a share [spec: shares/delete-non-creator]', async () => { const { app, db } = await createTestApp() await insertStorage(db) @@ -927,7 +920,7 @@ describe('GET /api/shares?box=received', () => { return rows[0].id } - it('lists shares addressed to the user by id and by email, hiding unrelated shares', async () => { + it('lists shares addressed to the user by id and by email, hiding unrelated shares [spec: shares/received-list]', async () => { const { app, db } = await createTestApp() const creatorHeaders = await authedHeaders(app) const recipientHeaders = await authedHeaders(app, 'recipient@example.com') @@ -970,7 +963,7 @@ describe('GET /api/shares?box=received', () => { expect(bystanderBody.total).toBe(0) }) - it('excludes revoked shares from the received list', async () => { + it('excludes revoked shares from the received list [spec: shares/received-excludes-revoked]', async () => { const { app, db } = await createTestApp() const creatorHeaders = await authedHeaders(app) const recipientHeaders = await authedHeaders(app, 'revoked-recipient@example.com') diff --git a/server/routes/shares.ts b/server/http/shares.ts similarity index 72% rename from server/routes/shares.ts rename to server/http/shares.ts index e482c491..bafebf14 100644 --- a/server/routes/shares.ts +++ b/server/http/shares.ts @@ -1,50 +1,25 @@ import { zValidator } from '@hono/zod-validator' -import { and, eq, or, sql } from 'drizzle-orm' import { Hono } from 'hono' import { getCookie, setCookie } from 'hono/cookie' import { z } from 'zod' import { DirType } from '../../shared/constants' import { createShareRequestSchema, listSharesQuerySchema, saveShareRequestSchema } from '../../shared/schemas/share' -import { user } from '../db/auth-schema' -import { matters } from '../db/schema' +import { isAccessibleByUser } from '../domain/share' +import { verifyPassword as verifyPasswordHash } from '../lib/password' import { requireAuth, requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { refundTraffic } from '../services/effective-quota' -import { listMatters } from '../services/matter' -import { canWriteToOrg } from '../services/org' -import { - computeSourceBytes, - isQuotaSufficient, - saveShareToDrive as saveShareToDriveService, -} from '../services/save-to-drive' -import { - createShare, - decrementDownloads, - getShareCreatorByToken, - hasDownloadsAvailable, - incrementDownloadsAtomic, - incrementViews, - isAccessibleByUser, - listReceivedSharesForApi, - listSharesForApi, - resolveShareByToken, - revokeShareByToken, - verifyPassword, -} from '../services/share' -import { dispatchShareCreated } from '../services/share-notification' -import { getStorage } from '../services/storage' +import { CreateShareError, type ShareRecord } from '../usecases/ports' +import { saveShareToDrive } from '../usecases/save-to-drive' +import { dispatchShareCreated } from '../usecases/share-notification' import { buildBreadcrumb, checkAccessGate, cookieName, decodeChildRef, encodeChildRef, - escapeLike, folderRootPath, PRESIGN_TTL_SECS, readUserId, - s3, viewCookieName, } from './share-utils' import { consumeAndReportDownloadTraffic } from './traffic-metering-utils' @@ -68,9 +43,8 @@ const VIEW_DEDUP_TTL_SECS = 30 export const publicShares = new Hono() .get('/:token', async (c) => { const token = c.req.param('token') - const db = c.get('platform').db - const resolved = await resolveShareByToken(db, token) + const resolved = await c.get('deps').share.resolveByToken(token) if (resolved.status !== 'ok') { if (resolved.status === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410) return c.json({ error: 'Share not found or revoked' }, 404) @@ -87,7 +61,7 @@ export const publicShares = new Hono() const viewCookie = getCookie(c, viewCookieName(token)) if (!isCreator && viewCookie !== 'seen') { - await incrementViews(db, share.id) + await c.get('deps').share.incrementViews(share.id) setCookie(c, viewCookieName(token), 'seen', { httpOnly: true, sameSite: 'Lax', @@ -103,8 +77,7 @@ export const publicShares = new Hono() const exhausted = !!(share.downloadLimit != null && share.downloads >= share.downloadLimit) const isFolder = matter.dirtype !== DirType.FILE - const creatorRows = await db.select({ name: user.name }).from(user).where(eq(user.id, share.creatorId)) - const creatorName = creatorRows[0]?.name ?? '' + const creatorName = (await c.get('deps').share.getCreatorName(share.creatorId)) ?? '' const base = { token: share.token, @@ -138,16 +111,16 @@ export const publicShares = new Hono() }) .post('/:token/sessions', zValidator('json', verifyPasswordSchema), async (c) => { const token = c.req.param('token') - const db = c.get('platform').db const { password } = c.req.valid('json') - const resolved = await resolveShareByToken(db, token) + const resolved = await c.get('deps').share.resolveByToken(token) if (resolved.status !== 'ok') return c.json({ error: 'Share not found or revoked' }, 404) const { share } = resolved if (share.kind !== 'landing') return c.json({ error: 'Share not found or revoked' }, 404) - if (!verifyPassword(share, password)) return c.json({ error: 'Invalid password' }, 403) + if (!share.passwordHash || !verifyPasswordHash(share.passwordHash, password)) + return c.json({ error: 'Invalid password' }, 403) const now = new Date() const oneDayMs = 24 * 60 * 60 * 1000 @@ -166,9 +139,8 @@ export const publicShares = new Hono() }) .get('/:token/objects', zValidator('query', listObjectsQuerySchema), async (c) => { const token = c.req.param('token') - const db = c.get('platform').db - const resolved = await resolveShareByToken(db, token) + const resolved = await c.get('deps').share.resolveByToken(token) if (resolved.status !== 'ok') { if (resolved.status === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410) return c.json({ error: 'Share not found or revoked' }, 404) @@ -196,7 +168,7 @@ export const publicShares = new Hono() const root = folderRootPath(matter) const queryParent = relativePath ? `${root}/${relativePath}` : root - const result = await listMatters(db, matter.orgId, { + const result = await c.get('deps').matter.list(matter.orgId, { parent: queryParent, status: 'active', page, @@ -223,9 +195,8 @@ export const publicShares = new Hono() const token = c.req.param('token') const ref = c.req.param('ref') const returnUrl = c.req.query('downloadUrl') === '1' - const db = c.get('platform').db - const resolved = await resolveShareByToken(db, token) + const resolved = await c.get('deps').share.resolveByToken(token) if (resolved.status !== 'ok') { if (resolved.status === 'matter_trashed') return c.json({ error: 'File no longer available' }, 410) return c.json({ error: 'Share not found or revoked' }, 404) @@ -247,32 +218,20 @@ export const publicShares = new Hono() let targetMatter = matter if (matterId !== matter.id) { if (matter.dirtype === DirType.FILE) return c.json({ error: 'File not found or not accessible' }, 404) - const root = folderRootPath(matter) - const likePattern = `${escapeLike(root)}/%` - const rows = await db - .select() - .from(matters) - .where( - and( - eq(matters.id, matterId), - eq(matters.orgId, matter.orgId), - eq(matters.status, 'active'), - or(eq(matters.parent, root), sql`${matters.parent} LIKE ${likePattern} ESCAPE '\\'`), - ), - ) - const child = rows[0] + const child = await c.get('deps').share.findShareChildMatter(matter, matterId) if (!child) return c.json({ error: 'File not found or not accessible' }, 404) targetMatter = child } else if (matter.dirtype !== DirType.FILE) { return c.json({ error: 'Cannot download a folder directly' }, 400) } - if (!(await hasDownloadsAvailable(db, share.id))) return c.json({ error: 'Download limit exceeded' }, 410) + if (!(await c.get('deps').share.hasDownloadsAvailable(share.id))) + return c.json({ error: 'Download limit exceeded' }, 410) - const storage = await getStorage(db, targetMatter.storageId) + const storage = await c.get('deps').storages.get(targetMatter.storageId) if (!storage) return c.json({ error: 'Storage not found' }, 404) - const { ok } = await incrementDownloadsAtomic(db, share.id) + const { ok } = await c.get('deps').share.incrementDownloadsAtomic(share.id) if (!ok) return c.json({ error: 'Download limit exceeded' }, 410) const trafficError = await consumeAndReportDownloadTraffic(c, { @@ -282,7 +241,7 @@ export const publicShares = new Hono() source: 'landing_share', sourceId: share.id, quotaExceeded: () => c.json({ error: 'Traffic quota exceeded' }, 422), - onRejected: () => decrementDownloads(db, share.id), + onRejected: () => c.get('deps').share.decrementDownloads(share.id), }) if (trafficError) return trafficError @@ -292,15 +251,15 @@ export const publicShares = new Hono() const actorId = viewerId ?? share.creatorId let url: string try { - url = await s3.presignDownload(storage, targetMatter.object, targetMatter.name, PRESIGN_TTL_SECS) + url = await c.get('deps').s3.presignDownload(storage, targetMatter.object, targetMatter.name, PRESIGN_TTL_SECS) } catch (e) { - await refundTraffic(db, share.orgId, targetMatter.size ?? 0) - await decrementDownloads(db, share.id) + await c.get('deps').quota.refundTraffic(share.orgId, targetMatter.size ?? 0) + await c.get('deps').share.decrementDownloads(share.id) throw e } try { - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: share.orgId, userId: actorId, action: 'share_download', @@ -331,37 +290,34 @@ export const authedShares = new Hono() .use(requireAuth) .get('/', zValidator('query', listSharesQuerySchema), async (c) => { const userId = c.get('userId')! - const db = c.get('platform').db const { page, pageSize, status, box } = c.req.valid('query') if (box === 'received') { - const emails = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1) - const result = await listReceivedSharesForApi(db, userId, emails[0]?.email ?? null, { page, pageSize }) + const email = await c.get('deps').share.getUserEmail(userId) + const result = await c.get('deps').share.listReceivedForApi(userId, email, { page, pageSize }) return c.json({ ...result, page, pageSize }) } - const result = await listSharesForApi(db, userId, { page, pageSize, status }) + const result = await c.get('deps').share.listForApi(userId, { page, pageSize, status }) return c.json({ ...result, page, pageSize }) }) .post('/', requireTeamRole('editor'), zValidator('json', createShareRequestSchema), async (c) => { const orgId = c.get('orgId')! const userId = c.get('userId')! - const db = c.get('platform').db const body = c.req.valid('json') let expiresAt: Date | undefined if (body.expiresAt) expiresAt = new Date(body.expiresAt) - const [creatorRow, matterRow] = await Promise.all([ - db.select({ name: user.name }).from(user).where(eq(user.id, userId)).limit(1), - db.select({ name: matters.name }).from(matters).where(eq(matters.id, body.matterId)).limit(1), + const [creatorNameRaw, matterName] = await Promise.all([ + c.get('deps').share.getCreatorName(userId), + c.get('deps').share.getMatterName(body.matterId), ]) - const creatorName = creatorRow[0]?.name ?? 'Unknown' - const matterName = matterRow[0]?.name + const creatorName = creatorNameRaw ?? 'Unknown' - let share: Awaited> + let share: ShareRecord try { - share = await createShare(db, { + share = await c.get('deps').share.create({ matterId: body.matterId, orgId, creatorId: userId, @@ -372,14 +328,15 @@ export const authedShares = new Hono() recipients: body.recipients, }) } catch (err) { - const msg = err instanceof Error ? err.message : '' - if (msg === 'MATTER_NOT_FOUND') return c.json({ error: 'Matter not found', code: 'MATTER_NOT_FOUND' }, 404) - if (msg === 'DIRECT_NO_FOLDER') - return c.json({ error: 'Direct shares cannot be folders', code: 'DIRECT_NO_FOLDER' }, 400) - if (msg === 'DIRECT_NO_PASSWORD') - return c.json({ error: 'Direct shares cannot have a password', code: 'DIRECT_NO_PASSWORD' }, 400) - if (msg === 'DIRECT_NO_RECIPIENTS') - return c.json({ error: 'Direct shares cannot have recipients', code: 'DIRECT_NO_RECIPIENTS' }, 400) + if (err instanceof CreateShareError) { + if (err.code === 'MATTER_NOT_FOUND') return c.json({ error: 'Matter not found', code: 'MATTER_NOT_FOUND' }, 404) + if (err.code === 'DIRECT_NO_FOLDER') + return c.json({ error: 'Direct shares cannot be folders', code: 'DIRECT_NO_FOLDER' }, 400) + if (err.code === 'DIRECT_NO_PASSWORD') + return c.json({ error: 'Direct shares cannot have a password', code: 'DIRECT_NO_PASSWORD' }, 400) + if (err.code === 'DIRECT_NO_RECIPIENTS') + return c.json({ error: 'Direct shares cannot have recipients', code: 'DIRECT_NO_RECIPIENTS' }, 400) + } throw err } @@ -387,12 +344,17 @@ export const authedShares = new Hono() const recipients = body.recipients ?? [] if (recipients.length > 0) { - dispatchShareCreated(c.get('platform'), share, recipients, creatorName, resolvedMatterName).catch((err) => - console.error('[shares] dispatchShareCreated failed:', err), - ) + dispatchShareCreated( + c.get('deps'), + c.get('platform'), + { id: share.id, token: share.token, kind: share.kind as 'landing' | 'direct', expiresAt: share.expiresAt }, + recipients, + creatorName, + resolvedMatterName, + ).catch((err) => console.error('[shares] dispatchShareCreated failed:', err)) } - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'share_create', @@ -416,20 +378,19 @@ export const authedShares = new Hono() .delete('/:token', async (c) => { const userId = c.get('userId')! const orgId = c.get('orgId')! - const db = c.get('platform').db const token = c.req.param('token') - const creatorId = await getShareCreatorByToken(db, token) + const creatorId = await c.get('deps').share.getCreatorByToken(token) if (creatorId === null) return c.json({ error: 'Not found' }, 404) if (creatorId !== userId) return c.json({ error: 'Forbidden' }, 403) - // Race-safe: revokeShareByToken scopes the UPDATE to (token, creatorId). + // Race-safe: revokeByToken scopes the UPDATE to (token, creatorId). // A concurrent revoke or ownership change between the check above and this // call returns false — translate to 404 at the boundary. - const revoked = await revokeShareByToken(db, token, userId) + const revoked = await c.get('deps').share.revokeByToken(token, userId) if (!revoked) return c.json({ error: 'Not found' }, 404) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'share_revoke', @@ -443,9 +404,9 @@ export const authedShares = new Hono() const token = c.req.param('token') const { targetOrgId, targetParent } = c.req.valid('json') const currentUserId = c.get('userId')! - const db = c.get('platform').db + const deps = c.get('deps') - const resolution = await resolveShareByToken(db, token) + const resolution = await deps.share.resolveByToken(token) if (resolution.status === 'matter_trashed') { return c.json({ error: 'Share target has been deleted' }, 410) } @@ -470,17 +431,17 @@ export const authedShares = new Hono() return c.json({ error: 'Authentication required for password-protected share' }, 401) } - if (!(await canWriteToOrg(db, currentUserId, targetOrgId))) { + if (!(await deps.org.canWriteToOrg(currentUserId, targetOrgId))) { return c.json({ error: 'Forbidden' }, 403) } - const totalBytes = await computeSourceBytes(db, matter) - const quotaOk = await isQuotaSufficient(db, targetOrgId, totalBytes) + const totalBytes = await deps.share.computeSourceBytes(matter) + const quotaOk = await deps.share.hasQuotaForBytes(targetOrgId, totalBytes) if (!quotaOk) { return c.json({ error: 'Quota exceeded', code: 'QUOTA_EXCEEDED' }, 400) } - const result = await saveShareToDriveService(db, { + const result = await saveShareToDrive(deps, { share, matter, currentUserId, diff --git a/server/routes/site-invitations.integration.test.ts b/server/http/site-invitations.integration.test.ts similarity index 86% rename from server/routes/site-invitations.integration.test.ts rename to server/http/site-invitations.integration.test.ts index 46ca8df2..3d57c3c8 100644 --- a/server/routes/site-invitations.integration.test.ts +++ b/server/http/site-invitations.integration.test.ts @@ -21,13 +21,13 @@ describe('Admin Site Invitations API — auth guards', () => { vi.unstubAllGlobals() }) - it('GET / returns 401 without auth', async () => { + it('GET / returns 401 without auth [spec: site-invitations/admin-auth]', async () => { const { app } = await createTestApp() const res = await app.request('/api/admin/site-invitations') expect(res.status).toBe(401) }) - it('POST / returns 403 for a non-admin user', async () => { + it('POST / returns 403 for a non-admin user [spec: site-invitations/admin-only]', async () => { const { app } = await createTestApp() stubEmailProvider() await adminHeaders(app) @@ -57,7 +57,7 @@ describe('Admin Site Invitations API', () => { ]) } - it('creates an invitation and returns 201', async () => { + it('creates an invitation and returns 201 [spec: site-invitations/create]', async () => { const ctx = await createTestApp() stubEmailProvider() await seedEmailOptions(ctx) @@ -76,7 +76,7 @@ describe('Admin Site Invitations API', () => { expect(body.status).toBe('pending') }) - it('lists invitations with total count', async () => { + it('lists invitations with total count [spec: site-invitations/list]', async () => { const ctx = await createTestApp() stubEmailProvider() await seedEmailOptions(ctx) @@ -95,7 +95,7 @@ describe('Admin Site Invitations API', () => { expect(body.items[0]?.email).toBe('invitee@example.com') }) - it('resends an invitation and rotates the token', async () => { + it('resends an invitation and rotates the token [spec: site-invitations/resend]', async () => { const ctx = await createTestApp() stubEmailProvider() await seedEmailOptions(ctx) @@ -118,7 +118,7 @@ describe('Admin Site Invitations API', () => { expect(resent.token).not.toBe(created.token) }) - it('revokes an invitation', async () => { + it('revokes an invitation [spec: site-invitations/revoke]', async () => { const ctx = await createTestApp() stubEmailProvider() await seedEmailOptions(ctx) @@ -141,7 +141,7 @@ describe('Admin Site Invitations API', () => { expect(body.revoked).toBe(true) }) - it('returns 409 when creating a duplicate pending invitation', async () => { + it('returns 409 when creating a duplicate pending invitation [spec: site-invitations/duplicate]', async () => { const ctx = await createTestApp() stubEmailProvider() await seedEmailOptions(ctx) @@ -168,7 +168,7 @@ describe('Public Site Invitations API', () => { vi.unstubAllGlobals() }) - it('returns an invitation by token', async () => { + it('returns an invitation by token [spec: site-invitations/by-token]', async () => { const ctx = await createTestApp() stubEmailProvider() await ctx.db.insert(systemOptions).values([ @@ -186,8 +186,8 @@ describe('Public Site Invitations API', () => { .where(eq(authSchema.user.email, 'admin@example.com')) .limit(1) - const { createSiteInvitation } = await import('../services/site-invitations.js') - const invitation = await createSiteInvitation(ctx.db, admin.id, 'invitee@example.com') + const { createSiteInvitationRepo } = await import('../adapters/repos/site-invitations.js') + const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invitee@example.com') const res = await ctx.app.request(`/api/site-invitations/${invitation.token}`, { headers }) expect(res.status).toBe(200) diff --git a/server/routes/site-invitations.ts b/server/http/site-invitations.ts similarity index 73% rename from server/routes/site-invitations.ts rename to server/http/site-invitations.ts index a6a847e2..a25a19ba 100644 --- a/server/routes/site-invitations.ts +++ b/server/http/site-invitations.ts @@ -4,17 +4,8 @@ import { z } from 'zod' import type { SiteInvitation } from '../../shared/types' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import type { Database } from '../platform/interface' -import { recordActivity } from '../services/activity' -import { getEmailConfig, sendEmail } from '../services/email' -import { - createSiteInvitation, - getSiteInvitationByToken, - getSiteName, - listSiteInvitations, - resendSiteInvitation, - revokeSiteInvitation, -} from '../services/site-invitations' +import type { Platform } from '../platform/interface' +import type { EmailGateway } from '../usecases/ports' const paginationSchema = z.object({ page: z.coerce.number().int().min(1).default(1), @@ -35,17 +26,18 @@ function buildSignupInviteEmailHtml(data: { siteName: string; inviteLink: string } async function sendSiteInvitationEmail( - db: Database, + email: EmailGateway, + platform: Platform, + siteName: string, requestUrl: string, - email: string, + to: string, token: string, expiresAt: string, ) { - const siteName = await getSiteName(db) const inviteLink = new URL('/sign-up', requestUrl) inviteLink.searchParams.set('invite', token) - await sendEmail(db, { - to: email, + await email.send(platform, { + to, subject: `You're invited to register on ${siteName}`, html: buildSignupInviteEmailHtml({ siteName, inviteLink: inviteLink.toString(), expiresAt }), }) @@ -54,28 +46,35 @@ async function sendSiteInvitationEmail( export const adminSiteInvitations = new Hono() .use(requireAdmin) .get('/', zValidator('query', paginationSchema), async (c) => { - const db = c.get('platform').db const { page, pageSize } = c.req.valid('query') - const result = await listSiteInvitations(db, page, pageSize) + const result = await c.get('deps').siteInvitations.listSiteInvitations(page, pageSize) return c.json(result) }) .post('/', zValidator('json', createSchema), async (c) => { - const db = c.get('platform').db + const platform = c.get('platform') const userId = c.get('userId') if (!userId) return c.json({ error: 'Unauthorized' }, 401) const orgId = c.get('orgId')! const { email } = c.req.valid('json') - await getEmailConfig(db) + await c.get('deps').email.getConfig(platform) let invitation: SiteInvitation try { - invitation = await createSiteInvitation(db, userId, email) + invitation = await c.get('deps').siteInvitations.createSiteInvitation(userId, email) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create invitation' return c.json({ error: message }, 409) } - await sendSiteInvitationEmail(db, c.req.url, invitation.email, invitation.token, invitation.expiresAt) - await recordActivity(db, { + await sendSiteInvitationEmail( + c.get('deps').email, + platform, + await c.get('deps').siteInvitations.getSiteName(), + c.req.url, + invitation.email, + invitation.token, + invitation.expiresAt, + ) + await c.get('deps').activity.record({ orgId, userId, action: 'site_invitation_create', @@ -86,30 +85,37 @@ export const adminSiteInvitations = new Hono() return c.json(invitation, 201) }) .post('/:id/resend', async (c) => { - const db = c.get('platform').db + const platform = c.get('platform') const id = c.req.param('id') - const invitation = await resendSiteInvitation(db, id) + const invitation = await c.get('deps').siteInvitations.resendSiteInvitation(id) if (invitation === 'not_found') return c.json({ error: 'Invitation not found' }, 404) if (invitation === 'already_accepted') return c.json({ error: 'Invitation has already been used' }, 400) if (invitation === 'already_revoked') return c.json({ error: 'Invitation has been revoked' }, 400) - await getEmailConfig(db) - await sendSiteInvitationEmail(db, c.req.url, invitation.email, invitation.token, invitation.expiresAt) + await c.get('deps').email.getConfig(platform) + await sendSiteInvitationEmail( + c.get('deps').email, + platform, + await c.get('deps').siteInvitations.getSiteName(), + c.req.url, + invitation.email, + invitation.token, + invitation.expiresAt, + ) return c.json(invitation) }) .delete('/:id', async (c) => { - const db = c.get('platform').db const userId = c.get('userId') if (!userId) return c.json({ error: 'Unauthorized' }, 401) const orgId = c.get('orgId')! const id = c.req.param('id') - const result = await revokeSiteInvitation(db, id, userId) + const result = await c.get('deps').siteInvitations.revokeSiteInvitation(id, userId) if (result === 'not_found') return c.json({ error: 'Invitation not found' }, 404) if (result === 'already_accepted') return c.json({ error: 'Invitation has already been used' }, 400) if (result === 'already_revoked') return c.json({ error: 'Invitation has already been revoked' }, 400) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'site_invitation_revoke', @@ -121,9 +127,8 @@ export const adminSiteInvitations = new Hono() }) export const publicSiteInvitations = new Hono().get('/:token', async (c) => { - const db = c.get('platform').db const token = c.req.param('token') - const invitation = await getSiteInvitationByToken(db, token) + const invitation = await c.get('deps').siteInvitations.getSiteInvitationByToken(token) if (!invitation) return c.json({ error: 'Invitation not found' }, 404) return c.json(invitation) }) diff --git a/server/routes/storages.cf-test.ts b/server/http/storages.cf-test.ts similarity index 95% rename from server/routes/storages.cf-test.ts rename to server/http/storages.cf-test.ts index a971d50c..06f5b43b 100644 --- a/server/routes/storages.cf-test.ts +++ b/server/http/storages.cf-test.ts @@ -2,11 +2,11 @@ import { env } from 'cloudflare:workers' import { eq } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import { FREE_STORAGE_LIMIT } from '../../shared/constants' +import { createStorageRepo } from '../adapters/repos/storage' import { createApp } from '../app' import { createAuth } from '../auth' import { user } from '../db/auth-schema' import { createCloudflarePlatform } from '../platform/cloudflare' -import { createStorage as insertStorage } from '../services/storage' async function buildApp() { const platform = createCloudflarePlatform(env) @@ -115,7 +115,7 @@ describe('[CF] Admin Storages API', () => { const app = await buildApp() const headers = await adminHeaders(app) const platform = createCloudflarePlatform(env) - const created = await insertStorage(platform.db, { + const created = await createStorageRepo(platform.db).create({ ...validStorage, title: `CF Detail ${Date.now()}`, bucket: `cf-detail-${Date.now()}`, @@ -131,7 +131,7 @@ describe('[CF] Admin Storages API', () => { const app = await buildApp() const headers = await adminHeaders(app) const platform = createCloudflarePlatform(env) - const created = await insertStorage(platform.db, { + const created = await createStorageRepo(platform.db).create({ ...validStorage, title: `CF Update ${Date.now()}`, bucket: `cf-update-${Date.now()}`, @@ -151,7 +151,7 @@ describe('[CF] Admin Storages API', () => { const app = await buildApp() const headers = await adminHeaders(app) const platform = createCloudflarePlatform(env) - const created = await insertStorage(platform.db, { + const created = await createStorageRepo(platform.db).create({ ...validStorage, title: `CF Delete ${Date.now()}`, bucket: `cf-delete-${Date.now()}`, diff --git a/server/routes/storages.integration.test.ts b/server/http/storages.integration.test.ts similarity index 88% rename from server/routes/storages.integration.test.ts rename to server/http/storages.integration.test.ts index d64bbacd..87975ed1 100644 --- a/server/routes/storages.integration.test.ts +++ b/server/http/storages.integration.test.ts @@ -1,7 +1,7 @@ import { FREE_STORAGE_LIMIT } from '@shared/constants' import { sql } from 'drizzle-orm' import { describe, expect, it } from 'vitest' -import { selectStorage } from '../services/storage.js' +import { createStorageRepo } from '../adapters/repos/storage.js' import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' const validStorage = { @@ -15,13 +15,13 @@ const validStorage = { } describe('Admin Storages API', () => { - it('returns 401 without auth', async () => { + it('returns 401 without auth [spec: storages/auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/admin/storages') expect(res.status).toBe(401) }) - it('returns 403 for non-admin user', async () => { + it('returns 403 for non-admin user [spec: storages/admin-only]', async () => { const { app } = await createTestApp() // First user becomes admin await authedHeaders(app, 'admin@example.com') @@ -46,7 +46,7 @@ describe('Admin Storages API', () => { expect(body).toEqual({ items: [], total: 0 }) }) - it('POST / creates a storage', async () => { + it('POST / creates a storage [spec: storages/create]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/storages', { @@ -65,7 +65,7 @@ describe('Admin Storages API', () => { expect(body.id).toBeTruthy() }) - it('POST / returns 402 when Community storage limit is reached', async () => { + it('POST / returns 402 when Community storage limit is reached [spec: storages/community-limit]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -91,7 +91,7 @@ describe('Admin Storages API', () => { expect(body.limit).toBe(FREE_STORAGE_LIMIT) }) - it('GET / lists created storages', async () => { + it('GET / lists created storages [spec: storages/list]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -109,7 +109,7 @@ describe('Admin Storages API', () => { expect(body.items[0].title).toBe('Test S3') }) - it('GET /:id returns storage detail', async () => { + it('GET /:id returns storage detail [spec: storages/detail]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -134,7 +134,7 @@ describe('Admin Storages API', () => { expect(res.status).toBe(404) }) - it('PUT /:id updates a storage', async () => { + it('PUT /:id updates a storage [spec: storages/update]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -167,7 +167,7 @@ describe('Admin Storages API', () => { expect(res.status).toBe(404) }) - it('DELETE /:id deletes a storage', async () => { + it('DELETE /:id deletes a storage [spec: storages/delete]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -197,7 +197,7 @@ describe('Admin Storages API', () => { expect(res.status).toBe(404) }) - it('DELETE /:id returns 409 when matters reference the storage', async () => { + it('DELETE /:id returns 409 when matters reference the storage [spec: storages/delete-in-use]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -246,11 +246,11 @@ async function insertStorage( } describe('selectStorage service', () => { - it('returns the single active storage when capacity is unlimited (0)', async () => { + it('returns the single active storage when capacity is unlimited (0) [spec: storages/select-active]', async () => { const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0 }) - const storage = await selectStorage(db, 'private') + const storage = await createStorageRepo(db).select('private') expect(storage.id).toBe('s1') }) @@ -258,7 +258,7 @@ describe('selectStorage service', () => { const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 50 }) - const storage = await selectStorage(db, 'private') + const storage = await createStorageRepo(db).select('private') expect(storage.id).toBe('s1') }) @@ -267,7 +267,7 @@ describe('selectStorage service', () => { await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 100, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 200, used: 50, createdAt: 2 }) - const storage = await selectStorage(db, 'private') + const storage = await createStorageRepo(db).select('private') expect(storage.id).toBe('s2') }) @@ -276,7 +276,7 @@ describe('selectStorage service', () => { await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 110, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 }) - const storage = await selectStorage(db, 'private') + const storage = await createStorageRepo(db).select('private') expect(storage.id).toBe('s2') }) @@ -285,7 +285,7 @@ describe('selectStorage service', () => { await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 }) - const storage = await selectStorage(db, 'private') + const storage = await createStorageRepo(db).select('private') expect(storage.id).toBe('s1') }) @@ -294,7 +294,7 @@ describe('selectStorage service', () => { await insertStorage(db, { id: 's1', mode: 'private', status: 'disabled', capacity: 0, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'private', status: 'active', capacity: 0, createdAt: 2 }) - const storage = await selectStorage(db, 'private') + const storage = await createStorageRepo(db).select('private') expect(storage.id).toBe('s2') }) @@ -302,13 +302,13 @@ describe('selectStorage service', () => { const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 }) - await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage') + await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage') }) 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 all storages of the mode are at full capacity', async () => { @@ -316,14 +316,14 @@ describe('selectStorage service', () => { await insertStorage(db, { id: 's1', mode: 'private', capacity: 50, used: 50 }) await insertStorage(db, { id: 's2', mode: 'private', capacity: 100, used: 100 }) - await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage') + await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage') }) it('returns a public storage when mode is public', async () => { const { db } = await createTestApp() await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 }) - const storage = await selectStorage(db, 'public') + const storage = await createStorageRepo(db).select('public') expect(storage.id).toBe('s1') }) @@ -332,7 +332,7 @@ describe('selectStorage service', () => { await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, createdAt: 1 }) await insertStorage(db, { id: 's2', mode: 'public', capacity: 0, createdAt: 2 }) - const storage = await selectStorage(db, 'private') + const storage = await createStorageRepo(db).select('private') expect(storage.id).toBe('s1') }) }) diff --git a/server/routes/storages.ts b/server/http/storages.ts similarity index 75% rename from server/routes/storages.ts rename to server/http/storages.ts index 17865356..423e08b7 100644 --- a/server/routes/storages.ts +++ b/server/http/storages.ts @@ -2,18 +2,10 @@ import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { FREE_STORAGE_LIMIT } from '../../shared/constants' import { createStorageSchema, updateStorageSchema } from '../../shared/schemas' -import { hasFeature, loadBindingState } from '../licensing/has-feature' +import { hasFeature } from '../domain/licensing' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { - countStorages, - createStorage, - deleteStorage, - getStorage, - listStorages, - updateStorage, -} from '../services/storage' +import { loadBindingState } from '../usecases/licensing' function enablesEgressCreditBilling(input: { egressCreditBillingEnabled?: boolean }) { return input.egressCreditBillingEnabled === true @@ -22,15 +14,13 @@ function enablesEgressCreditBilling(input: { egressCreditBillingEnabled?: boolea const app = new Hono() .use(requireAdmin) .get('/', async (c) => { - const db = c.get('platform').db - const result = await listStorages(db) + const result = await c.get('deps').storages.list() return c.json(result) }) .post('/', zValidator('json', createStorageSchema), async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! - const [total, state] = await Promise.all([countStorages(db), loadBindingState(db)]) + const [total, state] = await Promise.all([c.get('deps').storages.count(), loadBindingState(c.get('deps'))]) if (!hasFeature('storages_unlimited', state) && total >= FREE_STORAGE_LIMIT) { return c.json( { @@ -46,8 +36,8 @@ const app = new Hono() if (enablesEgressCreditBilling(input) && !hasFeature('quota_store', state)) { return c.json({ error: 'feature_not_available', feature: 'quota_store' }, 402) } - const storage = await createStorage(db, input) - await recordActivity(db, { + const storage = await c.get('deps').storages.create(input) + await c.get('deps').activity.record({ orgId, userId, action: 'storage_create', @@ -59,24 +49,22 @@ const app = new Hono() return c.json(storage, 201) }) .get('/:id', async (c) => { - const db = c.get('platform').db const id = c.req.param('id') - const storage = await getStorage(db, id) + const storage = await c.get('deps').storages.get(id) if (!storage) return c.json({ error: 'Storage not found' }, 404) return c.json(storage) }) .put('/:id', zValidator('json', updateStorageSchema), async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! const id = c.req.param('id') const input = c.req.valid('json') - if (enablesEgressCreditBilling(input) && !hasFeature('quota_store', await loadBindingState(db))) { + if (enablesEgressCreditBilling(input) && !hasFeature('quota_store', await loadBindingState(c.get('deps')))) { return c.json({ error: 'feature_not_available', feature: 'quota_store' }, 402) } - const storage = await updateStorage(db, id, input) + const storage = await c.get('deps').storages.update(id, input) if (!storage) return c.json({ error: 'Storage not found' }, 404) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'storage_update', @@ -88,15 +76,14 @@ const app = new Hono() return c.json(storage) }) .delete('/:id', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! const id = c.req.param('id') - const existing = await getStorage(db, id) - const result = await deleteStorage(db, id) + const existing = await c.get('deps').storages.get(id) + const result = await c.get('deps').storages.delete(id) if (result === 'not_found') return c.json({ error: 'Storage not found' }, 404) if (result === 'in_use') return c.json({ error: 'Storage is referenced by existing files' }, 409) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'storage_delete', diff --git a/server/routes/system.cf-test.ts b/server/http/system.cf-test.ts similarity index 100% rename from server/routes/system.cf-test.ts rename to server/http/system.cf-test.ts diff --git a/server/routes/system.integration.test.ts b/server/http/system.integration.test.ts similarity index 90% rename from server/routes/system.integration.test.ts rename to server/http/system.integration.test.ts index 83e26bf0..5f679ff4 100644 --- a/server/routes/system.integration.test.ts +++ b/server/http/system.integration.test.ts @@ -5,7 +5,7 @@ import { CAPTCHA_SECRET_OPTION_KEY, CAPTCHA_SITE_KEY_KEY, } from '../../shared/captcha.js' -import { resetChangelogCache } from '../services/changelog.js' +import { resetChangelogCache } from '../adapters/providers/changelog.js' import { adminHeaders, createTestApp } from '../test/setup.js' async function putOption( @@ -22,13 +22,13 @@ async function putOption( } describe('System API — options CRUD', () => { - it('GET unknown key returns 404', async () => { + it('GET unknown key returns 404 [spec: system/option-not-found]', async () => { const { app } = await createTestApp() const res = await app.request('/api/system/options/site_name') expect(res.status).toBe(404) }) - it('full admin CRUD lifecycle with public/private visibility', async () => { + it('full admin CRUD lifecycle with public/private visibility [spec: system/admin-crud]', async () => { const { app } = await createTestApp() const admin = await adminHeaders(app) @@ -79,7 +79,7 @@ describe('System API — options CRUD', () => { expect(afterDel.status).toBe(404) }) - it('unauthenticated mutations are rejected', async () => { + it('unauthenticated mutations are rejected [spec: system/mutations-require-admin]', async () => { const { app } = await createTestApp() const res = await app.request('/api/system/options/site_name', { method: 'PUT', @@ -92,7 +92,7 @@ describe('System API — options CRUD', () => { expect(del.status).toBe(401) }) - it('rejects invalid default organization quota values', async () => { + it('rejects invalid default organization quota values [spec: system/validate-org-quota]', async () => { const { app } = await createTestApp() const admin = await adminHeaders(app) @@ -102,7 +102,7 @@ describe('System API — options CRUD', () => { } }) - it('validates default monthly traffic quota values', async () => { + it('validates default monthly traffic quota values [spec: system/validate-traffic-quota]', async () => { const { app } = await createTestApp() const admin = await adminHeaders(app) @@ -120,7 +120,7 @@ describe('System API — options CRUD', () => { await expect(updated.json()).resolves.toMatchObject({ value: '0' }) }) - it('exposes instance info to admins only', async () => { + it('exposes instance info to admins only [spec: system/instance-info-admin-only]', async () => { const { app } = await createTestApp() const anon = await app.request('/api/system/instance') @@ -142,7 +142,7 @@ describe('System API — options CRUD', () => { resetChangelogCache() }) - it('serves the release version and changelog markdown to admins only', async () => { + it('serves the release version and changelog markdown to admins only [spec: system/changelog-admin-only]', async () => { const { app } = await createTestApp() resetChangelogCache() const markdown = '## [2.8.0] - 2026-07-01\n- product-facing notes' @@ -175,7 +175,7 @@ describe('System API — options CRUD', () => { }) }) - it('keeps captcha secret private and rejects enabling captcha before keys exist', async () => { + it('keeps captcha secret private and rejects enabling captcha before keys exist [spec: system/captcha-secret-private]', async () => { const { app } = await createTestApp() const admin = await adminHeaders(app) diff --git a/server/routes/system.test.ts b/server/http/system.test.ts similarity index 100% rename from server/routes/system.test.ts rename to server/http/system.test.ts diff --git a/server/routes/system.ts b/server/http/system.ts similarity index 66% rename from server/routes/system.ts rename to server/http/system.ts index c3ed9245..fb5a894d 100644 --- a/server/routes/system.ts +++ b/server/http/system.ts @@ -1,5 +1,4 @@ import { zValidator } from '@hono/zod-validator' -import { eq } from 'drizzle-orm' import { Hono } from 'hono' import { z } from 'zod' import { @@ -11,15 +10,15 @@ import { } from '../../shared/captcha' import { SignupMode } from '../../shared/constants' import { compareSemver } from '../../shared/semver' -import { systemOptions } from '../db/schema' -import { hasFeature, loadBindingState } from '../licensing/has-feature' -import { buildInstanceInfo, runtimeInfo } from '../licensing/instance-info' +import { readCaptchaConfig } from '../domain/captcha' +import { hasFeature } from '../domain/licensing' +import { originFromRequestUrl } from '../domain/site-public-origin' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { loadCaptchaOptionValues, readCaptchaConfig } from '../services/captcha' -import { fetchChangelog } from '../services/changelog' -import { getSitePublicOrigin, originFromRequestUrl } from '../services/site-public-origin' +import { loadCaptchaOptionValues } from '../usecases/captcha' +import { buildInstanceInfo, runtimeInfo } from '../usecases/instance-info' +import { loadBindingState } from '../usecases/licensing' +import { getSitePublicOrigin } from '../usecases/site-public-origin' import { getAppVersion } from '../version' const setOptionSchema = z.object({ @@ -30,40 +29,33 @@ const setOptionSchema = z.object({ const app = new Hono() .get('/instance', requireAdmin, async (c) => { const platform = c.get('platform') - const db = platform.db - const origin = (await getSitePublicOrigin(db)) ?? originFromRequestUrl(c.req.url) ?? new URL(c.req.url).origin - const info = await buildInstanceInfo(db, { url: origin, runtime: runtimeInfo(platform) }) + const origin = + (await getSitePublicOrigin(c.get('deps'))) ?? originFromRequestUrl(c.req.url) ?? new URL(c.req.url).origin + const info = await buildInstanceInfo(c.get('deps'), { url: origin, runtime: runtimeInfo(platform) }) return c.json(info) }) .get('/changelog', requireAdmin, zValidator('query', z.object({ refresh: z.string().optional() })), async (c) => { const force = c.req.valid('query').refresh === 'true' - const { latestVersion, markdown } = await fetchChangelog(Date.now(), { force }) + const { latestVersion, markdown } = await c.get('deps').changelog.fetchChangelog(Date.now(), { force }) const currentVersion = getAppVersion() const updateAvailable = latestVersion ? compareSemver(latestVersion, currentVersion) > 0 : false return c.json({ currentVersion, latestVersion, updateAvailable, markdown }) }) .get('/options', async (c) => { - const db = c.get('platform').db const isAdmin = c.get('userRole') === 'admin' - const rows = isAdmin - ? await db.select().from(systemOptions) - : await db.select().from(systemOptions).where(eq(systemOptions.public, true)) - const items = rows.map((r) => ({ key: r.key, value: r.value, public: !!r.public })) + const items = isAdmin ? await c.get('deps').systemOptions.list() : await c.get('deps').systemOptions.listPublic() return c.json({ items, total: items.length }) }) .get('/options/:key', async (c) => { - const db = c.get('platform').db const key = c.req.param('key') - const rows = await db.select().from(systemOptions).where(eq(systemOptions.key, key)) - const row = rows[0] + const row = await c.get('deps').systemOptions.get(key) if (!row) return c.json({ error: 'Option not found' }, 404) if (!row.public && c.get('userRole') !== 'admin') { return c.json({ error: 'Forbidden' }, 403) } - return c.json({ key: row.key, value: row.value, public: !!row.public }) + return c.json({ key: row.key, value: row.value, public: row.public }) }) .put('/options/:key', requireAdmin, zValidator('json', setOptionSchema), async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! const key = c.req.param('key') @@ -72,7 +64,7 @@ const app = new Hono() let isPublic = body.public if (key === 'auth_signup_mode' && body.value === SignupMode.OPEN) { - const state = await loadBindingState(db) + const state = await loadBindingState(c.get('deps')) if (!hasFeature('open_registration', state)) { return c.json( { error: 'feature_not_available', feature: 'open_registration', upgrade_url: '/settings/billing' }, @@ -90,7 +82,7 @@ const app = new Hono() } if (key === CAPTCHA_PROVIDER_KEY || key === CAPTCHA_MIN_SCORE_KEY || key.startsWith('captcha_')) { - const captchaValues = await loadCaptchaOptionValues(db) + const captchaValues = await loadCaptchaOptionValues(c.get('deps')) captchaValues[key] = value try { readCaptchaConfig(captchaValues) @@ -116,14 +108,11 @@ const app = new Hono() } } - const existing = await db - .select({ key: systemOptions.key, public: systemOptions.public }) - .from(systemOptions) - .where(eq(systemOptions.key, key)) - if (existing.length > 0) { - const nextPublic = isPublic ?? existing[0].public - await db.update(systemOptions).set({ value, public: nextPublic }).where(eq(systemOptions.key, key)) - await recordActivity(db, { + const existing = await c.get('deps').systemOptions.get(key) + if (existing) { + const nextPublic = isPublic ?? existing.public + await c.get('deps').systemOptions.set(key, value, nextPublic) + await c.get('deps').activity.record({ orgId, userId, action: 'system_option_set', @@ -134,8 +123,8 @@ const app = new Hono() return c.json({ key, value, public: !!nextPublic }) } const nextPublic = isPublic ?? false - await db.insert(systemOptions).values({ key, value, public: nextPublic }) - await recordActivity(db, { + await c.get('deps').systemOptions.set(key, value, nextPublic) + await c.get('deps').activity.record({ orgId, userId, action: 'system_option_set', @@ -146,12 +135,11 @@ const app = new Hono() return c.json({ key, value, public: !!nextPublic }, 201) }) .delete('/options/:key', requireAdmin, async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const orgId = c.get('orgId')! const key = c.req.param('key') - await db.delete(systemOptions).where(eq(systemOptions.key, key)) - await recordActivity(db, { + await c.get('deps').systemOptions.delete(key) + await c.get('deps').activity.record({ orgId, userId, action: 'system_option_delete', diff --git a/server/routes/teams-admin.integration.test.ts b/server/http/teams-admin.integration.test.ts similarity index 94% rename from server/routes/teams-admin.integration.test.ts rename to server/http/teams-admin.integration.test.ts index d8302dec..06a8e93f 100644 --- a/server/routes/teams-admin.integration.test.ts +++ b/server/http/teams-admin.integration.test.ts @@ -51,7 +51,7 @@ async function userId(db: TestDb, email: string): Promise { } describe('Admin Teams API', () => { - it('requires admin', async () => { + it('requires admin [spec: teams-admin/admin-only]', async () => { const { app } = await createTestApp() const noAuth = await app.request('/api/admin/teams') expect(noAuth.status).toBe(401) @@ -69,7 +69,7 @@ describe('Admin Teams API', () => { expect(forbidden.status).toBe(403) }) - it('lists only team orgs with usage, members, and owner, excluding personal spaces', async () => { + it('lists only team orgs with usage, members, and owner, excluding personal spaces [spec: teams-admin/list]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) const adminId = await userId(db, 'admin@example.com') @@ -95,7 +95,7 @@ describe('Admin Teams API', () => { expect(beta.ownerName).toBeNull() }) - it('returns a single team detail', async () => { + it('returns a single team detail [spec: teams-admin/detail]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedTeam(db, { id: 'team-x', name: 'Detail', quota: 10485760 }) @@ -108,7 +108,7 @@ describe('Admin Teams API', () => { expect(body.quotaTotal).toBe(10485760) }) - it('returns 404 for a missing or personal org', async () => { + it('returns 404 for a missing or personal org [spec: teams-admin/detail-not-found]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -124,7 +124,7 @@ describe('Admin Teams API', () => { }) describe('Admin Team Entitlements API', () => { - it('grants, lists, and revokes a storage entitlement for a team', async () => { + it('grants, lists, and revokes a storage entitlement for a team [spec: teams-admin/entitlement-lifecycle]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedTeam(db, { id: 'team-q1', name: 'Quota Team' }) @@ -160,7 +160,7 @@ describe('Admin Team Entitlements API', () => { expect(afterBody.items[0].status).toBe('revoked') }) - it('updates an admin grant bytes', async () => { + it('updates an admin grant bytes [spec: teams-admin/update-entitlement]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await seedTeam(db, { id: 'team-q2', name: 'Quota Team 2' }) @@ -182,7 +182,7 @@ describe('Admin Team Entitlements API', () => { expect(updated.entitlement.bytes).toBe(4096) }) - it('returns 404 for an unknown org and 403 for non-admin callers', async () => { + it('returns 404 for an unknown org and 403 for non-admin callers [spec: teams-admin/entitlement-guards]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const missing = await app.request('/api/admin/teams/no-such-org/entitlements', { headers }) diff --git a/server/routes/teams-admin.ts b/server/http/teams-admin.ts similarity index 83% rename from server/routes/teams-admin.ts rename to server/http/teams-admin.ts index a32588f0..571b464b 100644 --- a/server/routes/teams-admin.ts +++ b/server/http/teams-admin.ts @@ -3,14 +3,6 @@ import { Hono } from 'hono' import { z } from 'zod' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { - grantOrgEntitlement, - listOrgEntitlements, - revokeOrgEntitlement, - updateOrgEntitlement, -} from '../services/org-entitlements' -import { getTeam, listTeams } from '../services/team' const grantEntitlementSchema = z.object({ resourceType: z.literal('storage'), @@ -32,26 +24,25 @@ const updateEntitlementSchema = z.object({ export const adminTeams = new Hono() .use(requireAdmin) .get('/', async (c) => { - const items = await listTeams(c.get('platform').db) + const items = await c.get('deps').teams.listTeams() return c.json({ items, total: items.length }) }) .get('/:orgId', async (c) => { - const team = await getTeam(c.get('platform').db, c.req.param('orgId')) + const team = await c.get('deps').teams.getTeam(c.req.param('orgId')) if (!team) return c.json({ error: 'Team not found' }, 404) return c.json(team) }) .get('/:orgId/entitlements', async (c) => { - const result = await listOrgEntitlements(c.get('platform').db, c.req.param('orgId')) + const result = await c.get('deps').userAdmin.listOrgEntitlements(c.req.param('orgId')) if ('error' in result) return c.json({ error: result.error }, result.status) return c.json(result) }) .post('/:orgId/entitlements', zValidator('json', grantEntitlementSchema), async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const adminOrgId = c.get('orgId')! const targetOrgId = c.req.param('orgId') const body = c.req.valid('json') - const result = await grantOrgEntitlement(db, { + const result = await c.get('deps').userAdmin.grantOrgEntitlement({ adminUserId, orgId: targetOrgId, resourceType: body.resourceType, @@ -61,7 +52,7 @@ export const adminTeams = new Hono() }) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: adminOrgId, userId: adminUserId, action: 'quota_entitlement_grant', @@ -80,12 +71,11 @@ export const adminTeams = new Hono() return c.json(result, 201) }) .patch('/:orgId/entitlements/:eid', zValidator('json', updateEntitlementSchema), async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const adminOrgId = c.get('orgId')! const targetOrgId = c.req.param('orgId') const body = c.req.valid('json') - const result = await updateOrgEntitlement(db, { + const result = await c.get('deps').userAdmin.updateOrgEntitlement({ adminUserId, orgId: targetOrgId, entitlementId: c.req.param('eid'), @@ -95,7 +85,7 @@ export const adminTeams = new Hono() }) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: adminOrgId, userId: adminUserId, action: 'quota_entitlement_update', @@ -113,18 +103,17 @@ export const adminTeams = new Hono() return c.json(result) }) .delete('/:orgId/entitlements/:eid', async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const adminOrgId = c.get('orgId')! const targetOrgId = c.req.param('orgId') - const result = await revokeOrgEntitlement(db, { + const result = await c.get('deps').userAdmin.revokeOrgEntitlement({ adminUserId, orgId: targetOrgId, entitlementId: c.req.param('eid'), }) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: adminOrgId, userId: adminUserId, action: 'quota_entitlement_revoke', diff --git a/server/routes/teams.integration.test.ts b/server/http/teams.integration.test.ts similarity index 95% rename from server/routes/teams.integration.test.ts rename to server/http/teams.integration.test.ts index f338128d..0c24ba8b 100644 --- a/server/routes/teams.integration.test.ts +++ b/server/http/teams.integration.test.ts @@ -1,9 +1,9 @@ import { sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3.js' +import { createTeamInviteRepo } from '../adapters/repos/team-invite.js' import * as authSchema from '../db/auth-schema.js' -import { S3Service } from '../services/s3.js' -import { createInviteLink } from '../services/team-invite.js' import { createTestApp } from '../test/setup.js' type TestDb = Awaited>['db'] @@ -57,7 +57,7 @@ async function signUpAndGetUser(app: TestApp, email: string) { // ─── Public invite-info ──────────────────────────────────────────────────────── describe('GET /api/teams/invite-info', () => { - it('returns 400 when token is missing', async () => { + it('returns 400 when token is missing [spec: teams/invite-token-missing]', async () => { const { app } = await createTestApp() const res = await app.request('/api/teams/invite-info') expect(res.status).toBe(400) @@ -69,11 +69,11 @@ describe('GET /api/teams/invite-info', () => { expect(res.status).toBe(404) }) - it('returns invite info for a valid token without auth', async () => { + it('returns invite info for a valid token without auth [spec: teams/invite-info-public]', async () => { const { app, db } = await createTestApp() const orgId = await insertOrg(db, { name: 'My Team' }) const inviterId = await insertUser(db) - const link = await createInviteLink(db, orgId, inviterId, 'viewer') + const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer') const res = await app.request(`/api/teams/invite-info?token=${link.token}`) expect(res.status).toBe(200) @@ -112,7 +112,7 @@ describe('POST /api/teams/:teamId/invite-link', () => { expect(res.status).toBe(403) }) - it('returns 201 with token when owner creates an invite link', async () => { + it('returns 201 with token when owner creates an invite link [spec: teams/create-invite]', async () => { const { app, db } = await createTestApp() const email = `owner-${nanoid()}@example.com` const { headers, userId } = await signUpAndGetUser(app, email) @@ -153,7 +153,7 @@ describe('GET /api/teams/:teamId/invitations', () => { expect(res.status).toBe(403) }) - it('returns empty list when no pending invitations', async () => { + it('returns empty list when no pending invitations [spec: teams/list-pending-empty]', async () => { const { app, db } = await createTestApp() const email = `owner2-${nanoid()}@example.com` const { headers, userId } = await signUpAndGetUser(app, email) @@ -194,11 +194,11 @@ describe('POST /api/teams/:teamId/members', () => { expect(res.status).toBe(404) }) - it('returns 200 and joins the team with a valid token', async () => { + it('returns 200 and joins the team with a valid token [spec: teams/join]', async () => { const { app, db } = await createTestApp() 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') const email = `newmember-${nanoid()}@example.com` const { headers } = await signUpAndGetUser(app, email) @@ -211,11 +211,11 @@ describe('POST /api/teams/:teamId/members', () => { expect(res.status).toBe(200) }) - it('returns 409 when user is already a member', async () => { + it('returns 409 when user is already a member [spec: teams/join-already-member]', async () => { const { app, db } = await createTestApp() 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') const email = `existing-${nanoid()}@example.com` const { headers, userId } = await signUpAndGetUser(app, email) @@ -291,7 +291,7 @@ describe('GET /api/teams/:teamId/activity — auth', () => { // ─── Access control ──────────────────────────────────────────────────────────── describe('GET /api/teams/:teamId/activity — access control', () => { - it('returns 403 when authed user is not a member of a non-personal org', async () => { + it('returns 403 when authed user is not a member of a non-personal org [spec: teams/access-non-member]', async () => { const { app, db } = await createTestApp() // Sign up a user (their personal org is created automatically) @@ -326,7 +326,7 @@ describe('GET /api/teams/:teamId/activity — access control', () => { expect(res.status).toBe(200) }) - it('returns 200 when authed user accesses any personal org (personal orgs are public to auth users)', async () => { + it('returns 200 when authed user accesses any personal org (personal orgs are public to auth users) [spec: teams/access-personal-public]', async () => { const { app, db } = await createTestApp() // Sign up user1 @@ -346,7 +346,7 @@ describe('GET /api/teams/:teamId/activity — access control', () => { expect(res.status).toBe(200) }) - it('returns 200 when authed user is a member of a non-personal team org', async () => { + it('returns 200 when authed user is a member of a non-personal team org [spec: teams/access-team-member]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) const userId = await getUserId(db) @@ -379,7 +379,7 @@ describe('GET /api/teams/:teamId/activity — happy path', () => { expect(body.total).toBe(0) }) - it('returns activity items with user info when events exist', async () => { + it('returns activity items with user info when events exist [spec: teams/activity-feed]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) const orgId = await getOrgId(db) @@ -465,7 +465,7 @@ describe('GET /api/teams/:teamId/activity — pagination', () => { expect(body.pageSize).toBe(20) }) - it('respects explicit page and pageSize query params', async () => { + it('respects explicit page and pageSize query params [spec: teams/activity-pagination]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) const orgId = await getOrgId(db) @@ -535,7 +535,7 @@ describe('GET /api/teams/:teamId/activity — pagination', () => { // ─── Ordering ───────────────────────────────────────────────────────────────── describe('GET /api/teams/:teamId/activity — ordering', () => { - it('returns items ordered by newest first', async () => { + it('returns items ordered by newest first [spec: teams/activity-newest-first]', async () => { const { app, db } = await createTestApp() const headers = await authedHeaders(app) const orgId = await getOrgId(db) diff --git a/server/routes/teams.ts b/server/http/teams.ts similarity index 70% rename from server/routes/teams.ts rename to server/http/teams.ts index 9f652543..d1b8256d 100644 --- a/server/routes/teams.ts +++ b/server/http/teams.ts @@ -1,14 +1,8 @@ import { zValidator } from '@hono/zod-validator' -import { eq } from 'drizzle-orm' import { Hono } from 'hono' import { z } from 'zod' -import { organization } from '../db/auth-schema' import { requireAuth } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { listActivities, recordActivity } from '../services/activity' -import { deletePublicImageVariants, uploadPublicImage } from '../services/image-upload' -import { getMemberRole, isPersonalOrg } from '../services/org' -import { acceptInviteLink, createInviteLink, getInviteLinkInfo, listPendingInvitations } from '../services/team-invite' const LOGO_PREFIX = '_system/org-logos' @@ -30,9 +24,8 @@ export const publicTeams = new Hono().get( '/invite-info', zValidator('query', z.object({ token: z.string().min(1) })), async (c) => { - const db = c.get('platform').db const { token } = c.req.valid('query') - const info = await getInviteLinkInfo(db, token) + const info = await c.get('deps').teamInvites.getInviteLinkInfo(token) if (!info) return c.json({ error: 'Invalid or expired invite link' }, 404) return c.json(info) }, @@ -41,17 +34,16 @@ export const publicTeams = new Hono().get( export const teams = new Hono() .use(requireAuth) .post('/:teamId/invite-link', zValidator('json', createLinkSchema), async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const { teamId } = c.req.param() const { role, expiresIn } = c.req.valid('json') - const memberRole = await getMemberRole(db, teamId, userId) + const memberRole = await c.get('deps').org.getMemberRole(teamId, userId) if (memberRole !== 'owner') return c.json({ error: 'Forbidden' }, 403) - const link = await createInviteLink(db, teamId, userId, role, expiresIn) + const link = await c.get('deps').teamInvites.createInviteLink(teamId, userId, role, expiresIn) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: teamId, userId, action: 'team_invite_link_create', @@ -64,28 +56,26 @@ export const teams = new Hono() return c.json({ token: link.token, expiresAt: link.expiresAt }, 201) }) .get('/:teamId/invitations', async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const { teamId } = c.req.param() - const memberRole = await getMemberRole(db, teamId, userId) + const memberRole = await c.get('deps').org.getMemberRole(teamId, userId) if (memberRole !== 'owner') return c.json({ error: 'Forbidden' }, 403) - const invitations = await listPendingInvitations(db, teamId) + const invitations = await c.get('deps').teamInvites.listPendingInvitations(teamId) return c.json({ invitations }) }) .post('/:teamId/members', zValidator('json', joinSchema), async (c) => { - const db = c.get('platform').db const userId = c.get('userId')! const { token } = c.req.valid('json') const { teamId } = c.req.param() - const result = await acceptInviteLink(db, token, userId) + const result = await c.get('deps').teamInvites.acceptInviteLink(token, userId) if (result === 'invalid') return c.json({ error: 'Invalid invite link' }, 404) if (result === 'expired') return c.json({ error: 'Invite link has expired' }, 410) if (result === 'already_member') return c.json({ error: 'Already a member of this team' }, 409) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: teamId, userId, action: 'team_member_join', @@ -99,17 +89,16 @@ export const teams = new Hono() .get('/:teamId/activity', zValidator('query', activityQuerySchema), async (c) => { const userId = c.get('userId')! const teamId = c.req.param('teamId') - const db = c.get('platform').db - const role = await getMemberRole(db, teamId, userId) - if (role === null && !(await isPersonalOrg(db, teamId))) { + const role = await c.get('deps').org.getMemberRole(teamId, userId) + if (role === null && !(await c.get('deps').org.isPersonalOrg(teamId))) { return c.json({ error: 'Forbidden' }, 403) } const { page: pageStr, pageSize: pageSizeStr } = c.req.valid('query') const page = Number(pageStr ?? '1') const pageSize = Number(pageSizeStr ?? '20') - const result = await listActivities(db, teamId, { page, pageSize }) + const result = await c.get('deps').activity.list(teamId, { page, pageSize }) return c.json({ ...result, page, pageSize }) }) // ── Org logo (public-bucket image) ─────────────────────────────────────────── @@ -118,7 +107,7 @@ export const teams = new Hono() const userId = c.get('userId') as string const { teamId } = c.req.param() - const role = await getMemberRole(platform.db, teamId, userId) + const role = await c.get('deps').org.getMemberRole(teamId, userId) if (role !== 'owner' && role !== 'admin') return c.json({ error: 'Forbidden' }, 403) const form = await c.req.formData().catch(() => null) @@ -126,12 +115,12 @@ export const teams = new Hono() const file = form.get('file') if (!(file instanceof File)) return c.json({ error: 'file field is required' }, 400) - const result = await uploadPublicImage(platform, LOGO_PREFIX, teamId, file) + const result = await c.get('deps').imageUpload.uploadPublicImage(platform, LOGO_PREFIX, teamId, file) if (!result.ok) return c.json({ error: result.error }, result.status) - await platform.db.update(organization).set({ logo: result.url }).where(eq(organization.id, teamId)) + await c.get('deps').teams.setLogo(teamId, result.url) - await recordActivity(platform.db, { + await c.get('deps').activity.record({ orgId: teamId, userId, action: 'team_logo_update', @@ -147,13 +136,13 @@ export const teams = new Hono() const userId = c.get('userId') as string const { teamId } = c.req.param() - const role = await getMemberRole(platform.db, teamId, userId) + const role = await c.get('deps').org.getMemberRole(teamId, userId) if (role !== 'owner' && role !== 'admin') return c.json({ error: 'Forbidden' }, 403) - await platform.db.update(organization).set({ logo: null }).where(eq(organization.id, teamId)) - await deletePublicImageVariants(platform, LOGO_PREFIX, teamId) + await c.get('deps').teams.setLogo(teamId, null) + await c.get('deps').imageUpload.deletePublicImageVariants(platform, LOGO_PREFIX, teamId) - await recordActivity(platform.db, { + await c.get('deps').activity.record({ orgId: teamId, userId, action: 'team_logo_delete', diff --git a/server/routes/traffic-metering-utils.ts b/server/http/traffic-metering-utils.ts similarity index 85% rename from server/routes/traffic-metering-utils.ts rename to server/http/traffic-metering-utils.ts index ea18dbec..3eb57d16 100644 --- a/server/routes/traffic-metering-utils.ts +++ b/server/http/traffic-metering-utils.ts @@ -1,11 +1,11 @@ import type { Context } from 'hono' +import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import type { Env } from '../middleware/platform' import { CloudTrafficBlockedError, reportTrafficEgress, type TrafficReportSource, -} from '../services/cloud-traffic-metering' -import { consumeTrafficIfQuotaAllows, refundTraffic } from '../services/effective-quota' +} from '../usecases/cloud-traffic-metering' interface DownloadTrafficParams { orgId: string @@ -34,7 +34,7 @@ export async function consumeAndReportDownloadTraffic( c: Context, params: DownloadTrafficParams, ): Promise { - const allowed = await consumeTrafficIfQuotaAllows(c.get('platform').db, params.orgId, params.bytes) + const allowed = await c.get('deps').quota.consumeTrafficIfQuotaAllows(params.orgId, params.bytes) if (!allowed) { await params.onRejected?.() return params.quotaExceeded() @@ -66,8 +66,8 @@ export async function reportTrafficForDownload( }, ): Promise { try { - await reportTrafficEgress({ - platform: c.get('platform'), + await reportTrafficEgress(c.get('deps'), { + cloudBaseUrl: c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT, orgId: params.orgId, bytes: params.bytes, storageId: params.storage.id, @@ -79,7 +79,7 @@ export async function reportTrafficForDownload( }) return null } catch (error) { - await refundTraffic(c.get('platform').db, params.orgId, params.bytes) + await c.get('deps').quota.refundTraffic(params.orgId, params.bytes) await params.onRejected?.() if (error instanceof CloudTrafficBlockedError) { return c.json({ error: 'insufficient_credits', code: 'insufficient_credits', resource: 'storage_egress' }, 402) diff --git a/server/routes/trash.ts b/server/http/trash.ts similarity index 63% rename from server/routes/trash.ts rename to server/http/trash.ts index e47e6998..4801a6a4 100644 --- a/server/routes/trash.ts +++ b/server/http/trash.ts @@ -1,24 +1,21 @@ import { Hono } from 'hono' import { requireAuth, requireTeamRole } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { collectForPurge, listTrashedRoots } from '../services/matter' -import { purgeRecursively } from '../services/purge' +import { purgeRecursively } from '../usecases/purge' const app = new Hono().use(requireAuth).delete('/', requireTeamRole('editor'), async (c) => { const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) const userId = c.get('userId')! - const db = c.get('platform').db - const roots = await listTrashedRoots(db, orgId) + const roots = await c.get('deps').matter.listTrashedRoots(orgId) let purgedCount = 0 for (const root of roots) { - const ms = await collectForPurge(db, orgId, root.id) + const ms = await c.get('deps').matter.collectForPurge(orgId, root.id) if (!ms) continue - purgedCount += await purgeRecursively(db, orgId, ms) + purgedCount += await purgeRecursively(c.get('deps'), orgId, ms) } if (purgedCount > 0) { - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId, action: 'trash_empty', diff --git a/server/routes/users.integration.test.ts b/server/http/users.integration.test.ts similarity index 95% rename from server/routes/users.integration.test.ts rename to server/http/users.integration.test.ts index e46c002c..b08f4d7a 100644 --- a/server/routes/users.integration.test.ts +++ b/server/http/users.integration.test.ts @@ -24,13 +24,13 @@ async function signUpUser(app: ReturnType, } describe('Admin Users API', () => { - it('returns 401 without auth', async () => { + it('returns 401 without auth [spec: users/auth-required]', async () => { const { app } = await createTestApp() const res = await app.request('/api/admin/users') expect(res.status).toBe(401) }) - it('returns 403 for non-admin user', async () => { + it('returns 403 for non-admin user [spec: users/admin-only]', async () => { const { app } = await createTestApp() // Create first user (auto-admin), then second user (non-admin) await authedHeaders(app, 'admin@example.com') @@ -46,7 +46,7 @@ describe('Admin Users API', () => { expect(res.status).toBe(403) }) - it('GET /api/admin/users lists users with pagination', async () => { + it('GET /api/admin/users lists users with pagination [spec: users/list]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -59,7 +59,7 @@ describe('Admin Users API', () => { expect(body.items[0].orgName).toBeTruthy() }) - it('GET /api/admin/users returns quota from the personal organization', async () => { + it('GET /api/admin/users returns quota from the personal organization [spec: users/quota-personal-org]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await signUpUser(app, 'quota-list@example.com') @@ -98,7 +98,7 @@ describe('Admin Users API', () => { }) }) - it('GET /api/admin/users computes quota total from active plan and extra storage entitlements', async () => { + it('GET /api/admin/users computes quota total from active plan and extra storage entitlements [spec: users/quota-entitlements]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await signUpUser(app, 'quota-plan@example.com') @@ -135,7 +135,7 @@ describe('Admin Users API', () => { }) }) - it('GET /api/admin/users filters by name, username, or email with filtered totals', async () => { + it('GET /api/admin/users filters by name, username, or email with filtered totals [spec: users/filter]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) await signUpUser(app, 'match-email@example.com', 'Email Match') @@ -159,7 +159,7 @@ describe('Admin Users API', () => { expect(byEmailBody.items[0].email).toBe('other@example.com') }) - it('PATCH /api/admin/users/:id disables a user', async () => { + it('PATCH /api/admin/users/:id disables a user [spec: users/disable]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -184,7 +184,7 @@ describe('Admin Users API', () => { expect(updated[0].banned).toBe(1) }) - it('PATCH /api/admin/users/:id rejects invalid status', async () => { + it('PATCH /api/admin/users/:id rejects invalid status [spec: users/invalid-status]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/users/someid', { @@ -195,7 +195,7 @@ describe('Admin Users API', () => { expect(res.status).toBe(400) }) - it('PATCH /api/admin/users/:id returns 404 for missing user', async () => { + it('PATCH /api/admin/users/:id returns 404 for missing user [spec: users/patch-missing]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) const res = await app.request('/api/admin/users/nonexistent', { @@ -206,7 +206,7 @@ describe('Admin Users API', () => { expect(res.status).toBe(404) }) - it('DELETE /api/admin/users/:id deletes a user', async () => { + it('DELETE /api/admin/users/:id deletes a user [spec: users/delete]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -227,7 +227,7 @@ describe('Admin Users API', () => { expect(remaining).toHaveLength(0) }) - it('disabled user is rejected by auth middleware on existing session', async () => { + it('disabled user is rejected by auth middleware on existing session [spec: users/disabled-session-rejected]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) @@ -261,7 +261,7 @@ describe('Admin Users API', () => { expect(res.status).toBe(404) }) - it('PATCH /api/admin/users/batch disables and enables users', async () => { + it('PATCH /api/admin/users/batch disables and enables users [spec: users/batch]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await signUpUser(app, 'batch1@example.com') @@ -291,7 +291,7 @@ describe('Admin Users API', () => { expect(enabled.every((row) => row.banned === 0)).toBe(true) }) - it('POST /api/admin/users/:id/entitlements grants storage entitlement for a personal org', async () => { + it('POST /api/admin/users/:id/entitlements grants storage entitlement for a personal org [spec: users/grant-entitlement]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await signUpUser(app, 'grant-storage@example.com') @@ -322,7 +322,7 @@ describe('Admin Users API', () => { expect(entitlements).toEqual([{ bytes: 123456, entitlementType: 'grant', source: 'admin_grant' }]) }) - it('PATCH /api/admin/users/:id/entitlements/:eid updates an admin grant', async () => { + it('PATCH /api/admin/users/:id/entitlements/:eid updates an admin grant [spec: users/update-entitlement]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) const user = (await signUpUser(app, 'edit-grant@example.com')) as { user: { id: string } } @@ -352,7 +352,7 @@ describe('Admin Users API', () => { expect(rows[0].expiresAt).toBe(new Date(expiresAt).getTime()) }) - it('DELETE /api/admin/users/:id/entitlements/:eid revokes an admin grant', async () => { + it('DELETE /api/admin/users/:id/entitlements/:eid revokes an admin grant [spec: users/revoke-entitlement]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) const user = (await signUpUser(app, 'revoke-grant@example.com')) as { user: { id: string } } @@ -487,7 +487,7 @@ describe('Admin Users API', () => { expect(del.status).toBe(404) }) - it('DELETE /api/admin/users/:id/entitlements/:eid rejects non-admin-grant sources', async () => { + it('DELETE /api/admin/users/:id/entitlements/:eid rejects non-admin-grant sources [spec: users/entitlement-source-guard]', async () => { const { app, db } = await createTestApp() const headers = await adminHeaders(app) await signUpUser(app, 'free-plan-revoke@example.com') diff --git a/server/routes/users.ts b/server/http/users.ts similarity index 81% rename from server/routes/users.ts rename to server/http/users.ts index 5a324f48..d95ef4b3 100644 --- a/server/routes/users.ts +++ b/server/http/users.ts @@ -3,19 +3,6 @@ import { Hono } from 'hono' import { z } from 'zod' import { requireAdmin } from '../middleware/auth' import type { Env } from '../middleware/platform' -import { recordActivity } from '../services/activity' -import { - deleteUser, - deleteUsers, - getUser, - grantUserPersonalEntitlement, - listUserPersonalEntitlements, - listUsers, - revokeUserPersonalEntitlement, - setUserStatus, - setUsersStatus, - updateUserPersonalEntitlement, -} from '../services/user' const updateStatusSchema = z.object({ status: z.enum(['active', 'disabled']), @@ -48,32 +35,29 @@ const updateEntitlementSchema = z.object({ const app = new Hono() .use(requireAdmin) .get('/', async (c) => { - const db = c.get('platform').db const page = Math.max(1, Number(c.req.query('page') ?? '1')) const pageSize = Math.min(100, Math.max(1, Number(c.req.query('pageSize') ?? '20'))) const search = c.req.query('search') - const result = await listUsers(db, page, pageSize, search) + const result = await c.get('deps').userAdmin.listUsers(page, pageSize, search) return c.json(result) }) .get('/:id', async (c) => { - const db = c.get('platform').db const userId = c.req.param('id') - const result = await getUser(db, userId) + const result = await c.get('deps').userAdmin.getUser(userId) if ('error' in result) return c.json({ error: result.error }, result.status) return c.json(result) }) .patch('/batch', zValidator('json', batchPatchSchema), async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const orgId = c.get('orgId')! const body = c.req.valid('json') const status = body.action === 'disable' ? 'disabled' : 'active' - const result = await setUsersStatus(db, body.ids, status) + const result = await c.get('deps').userAdmin.setUsersStatus(body.ids, status) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId: adminUserId, action: status === 'disabled' ? 'user_disable' : 'user_enable', @@ -84,19 +68,17 @@ const app = new Hono() return c.json({ ...result, status }) }) .get('/:id/entitlements', async (c) => { - const db = c.get('platform').db const userId = c.req.param('id') - const result = await listUserPersonalEntitlements(db, userId) + const result = await c.get('deps').userAdmin.listUserPersonalEntitlements(userId) if ('error' in result) return c.json({ error: result.error }, result.status) return c.json(result) }) .post('/:id/entitlements', zValidator('json', grantEntitlementSchema), async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const adminOrgId = c.get('orgId')! const targetUserId = c.req.param('id') const body = c.req.valid('json') - const result = await grantUserPersonalEntitlement(db, { + const result = await c.get('deps').userAdmin.grantUserPersonalEntitlement({ adminUserId, targetUserId, resourceType: body.resourceType, @@ -106,7 +88,7 @@ const app = new Hono() }) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: adminOrgId, userId: adminUserId, action: 'quota_entitlement_grant', @@ -125,13 +107,12 @@ const app = new Hono() return c.json(result, 201) }) .patch('/:id/entitlements/:eid', zValidator('json', updateEntitlementSchema), async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const adminOrgId = c.get('orgId')! const targetUserId = c.req.param('id') const entitlementId = c.req.param('eid') const body = c.req.valid('json') - const result = await updateUserPersonalEntitlement(db, { + const result = await c.get('deps').userAdmin.updateUserPersonalEntitlement({ adminUserId, targetUserId, entitlementId, @@ -141,7 +122,7 @@ const app = new Hono() }) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: adminOrgId, userId: adminUserId, action: 'quota_entitlement_update', @@ -159,15 +140,16 @@ const app = new Hono() return c.json(result) }) .delete('/:id/entitlements/:eid', async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const adminOrgId = c.get('orgId')! const targetUserId = c.req.param('id') const entitlementId = c.req.param('eid') - const result = await revokeUserPersonalEntitlement(db, { adminUserId, targetUserId, entitlementId }) + const result = await c + .get('deps') + .userAdmin.revokeUserPersonalEntitlement({ adminUserId, targetUserId, entitlementId }) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId: adminOrgId, userId: adminUserId, action: 'quota_entitlement_revoke', @@ -184,15 +166,14 @@ const app = new Hono() return c.json(result) }) .delete('/batch', zValidator('json', userIdsSchema), async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const orgId = c.get('orgId')! const { ids } = c.req.valid('json') - const result = await deleteUsers(db, ids) + const result = await c.get('deps').userAdmin.deleteUsers(ids) if ('error' in result) return c.json({ error: result.error }, result.status) - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId: adminUserId, action: 'user_delete', @@ -203,19 +184,18 @@ const app = new Hono() return c.json(result) }) .patch('/:id', zValidator('json', updateStatusSchema), async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const orgId = c.get('orgId')! const userId = c.req.param('id') const { status } = c.req.valid('json') - const updated = await setUserStatus(db, userId, status) + const updated = await c.get('deps').userAdmin.setUserStatus(userId, status) if (!updated) { return c.json({ error: 'User not found' }, 404) } const action = status === 'disabled' ? 'user_disable' : 'user_enable' - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId: adminUserId, action, @@ -228,17 +208,16 @@ const app = new Hono() return c.json({ id: userId, status }) }) .delete('/:id', async (c) => { - const db = c.get('platform').db const adminUserId = c.get('userId')! const orgId = c.get('orgId')! const userId = c.req.param('id') - const deleted = await deleteUser(db, userId) + const deleted = await c.get('deps').userAdmin.deleteUser(userId) if (!deleted) { return c.json({ error: 'User not found' }, 404) } - await recordActivity(db, { + await c.get('deps').activity.record({ orgId, userId: adminUserId, action: 'user_delete', diff --git a/server/routes/webdav.e2e.test.ts b/server/http/webdav.e2e.test.ts similarity index 99% rename from server/routes/webdav.e2e.test.ts rename to server/http/webdav.e2e.test.ts index f64e6836..2ad33f20 100644 --- a/server/routes/webdav.e2e.test.ts +++ b/server/http/webdav.e2e.test.ts @@ -5,8 +5,8 @@ import { serve } from '@hono/node-server' import { sql } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createClient } from 'webdav' +import { S3Service } from '../adapters/gateways/s3.js' import { storages } from '../db/schema.js' -import { S3Service } from '../services/s3.js' import { authedHeaders, createTestApp } from '../test/setup.js' type TestApp = Awaited> diff --git a/server/routes/webdav.integration.test.ts b/server/http/webdav.integration.test.ts similarity index 98% rename from server/routes/webdav.integration.test.ts rename to server/http/webdav.integration.test.ts index 4218ef1a..5acf72c3 100644 --- a/server/routes/webdav.integration.test.ts +++ b/server/http/webdav.integration.test.ts @@ -1,6 +1,6 @@ import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { S3Service } from '../services/s3.js' +import { S3Service } from '../adapters/gateways/s3.js' import { authedHeaders, createTestApp } from '../test/setup.js' type TestApp = Awaited> @@ -145,7 +145,7 @@ async function folder(db: TestApp['db'], orgId: string, opts: { id: string; name } describe('WebDAV API', () => { - it('rejects missing and insufficient API keys without accepting session cookies', async () => { + it('rejects missing and insufficient API keys without accepting session cookies [spec: webdav/auth]', async () => { const { app, db, auth } = await createTestApp() const headers = await authedHeaders(app) const { slug } = await org(db) @@ -171,7 +171,7 @@ describe('WebDAV API', () => { ).toBe(401) }) - it('rejects org-bound image-hosting API keys for WebDAV Basic Auth', async () => { + it('rejects org-bound image-hosting API keys for WebDAV Basic Auth [spec: webdav/auth-key-scope]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) const workspace = await org(db) @@ -186,7 +186,7 @@ describe('WebDAV API', () => { expect(res.headers.get('WWW-Authenticate')).toBe('Basic realm="ZPan WebDAV"') }) - it('PROPFIND lists the mount root, workspace root, and folder children', async () => { + it('PROPFIND lists the mount root, workspace root, and folder children [spec: webdav/propfind]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -245,7 +245,7 @@ describe('WebDAV API', () => { expect(byId.status).toBe(207) }) - it('PROPFIND mount root lists all member workspaces and hides non-member workspaces', async () => { + it('PROPFIND mount root lists all member workspaces and hides non-member workspaces [spec: webdav/propfind-workspaces]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) const workspace = await org(db) @@ -268,7 +268,7 @@ describe('WebDAV API', () => { expect(hiddenRes.status).toBe(404) }) - it('PROPFIND supports prop, propname, allprop include, explicit depths, and rejects infinity', async () => { + it('PROPFIND supports prop, propname, allprop include, explicit depths, and rejects infinity [spec: webdav/propfind-modes]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -337,7 +337,7 @@ describe('WebDAV API', () => { expect(await infinity.text()).toContain('propfind-finite-depth') }) - it('PROPPATCH stores and removes dead properties visible to later PROPFIND', async () => { + it('PROPPATCH stores and removes dead properties visible to later PROPFIND [spec: webdav/proppatch]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -452,7 +452,7 @@ describe('WebDAV API', () => { expect(await rootFind.text()).toContain('yes') }) - it('GET returns file bytes directly and HEAD returns coherent file headers', async () => { + it('GET returns file bytes directly and HEAD returns coherent file headers [spec: webdav/get]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -490,7 +490,7 @@ describe('WebDAV API', () => { expect(S3Service.prototype.getObjectBytes).not.toHaveBeenCalled() }) - it('GET consumes WebDAV traffic while HEAD does not', async () => { + it('GET consumes WebDAV traffic while HEAD does not [spec: webdav/get-traffic]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -595,7 +595,7 @@ describe('WebDAV API', () => { expect(reports).toEqual([]) }) - it('GET supports valid byte ranges and rejects invalid ranges', async () => { + it('GET supports valid byte ranges and rejects invalid ranges [spec: webdav/get-range]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -738,7 +738,7 @@ describe('WebDAV API', () => { expect(unknownUnit.headers.get('Content-Range')).toBeNull() }) - it('honors ETag preconditions and changes ETag after overwrite', async () => { + it('honors ETag preconditions and changes ETag after overwrite [spec: webdav/etag-preconditions]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -875,7 +875,7 @@ describe('WebDAV API', () => { expect(freshWrite.status).toBe(204) }) - it('OPTIONS advertises DAV methods', async () => { + it('OPTIONS advertises DAV methods [spec: webdav/options]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) const account = await userAccount(db) @@ -898,7 +898,7 @@ describe('WebDAV API', () => { expect(res.headers.get('WWW-Authenticate')).toBe('Basic realm="ZPan WebDAV"') }) - it('PUT creates a file matter and writes through configured storage', async () => { + it('PUT creates a file matter and writes through configured storage [spec: webdav/put-create]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -960,7 +960,7 @@ describe('WebDAV API', () => { expect(rows[0]).toEqual({ name: 'upload.txt', size: 9 }) }) - it('PUT updates an existing file matter and rejects collection writes', async () => { + it('PUT updates an existing file matter and rejects collection writes [spec: webdav/put-update]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -997,7 +997,7 @@ describe('WebDAV API', () => { expect(folderWrite.status).toBe(409) }) - it('PUT rolls back quota reservation when storage write fails', async () => { + it('PUT rolls back quota reservation when storage write fails [spec: webdav/put-rollback]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1016,7 +1016,7 @@ describe('WebDAV API', () => { expect(rows[0]?.used).toBe(0) }) - it('MKCOL creates a folder matter', async () => { + it('MKCOL creates a folder matter [spec: webdav/mkcol]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1035,7 +1035,7 @@ describe('WebDAV API', () => { expect(rows[0]?.dirtype).toBe(1) }) - it('MKCOL rejects existing targets and missing parent collections', async () => { + it('MKCOL rejects existing targets and missing parent collections [spec: webdav/mkcol-guards]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1071,7 +1071,7 @@ describe('WebDAV API', () => { expect(unsupportedBody.status).toBe(415) }) - it('MOVE, COPY, and DELETE stay within org scope; DELETE trashes instead of purging', async () => { + it('MOVE, COPY, and DELETE stay within org scope; DELETE trashes instead of purging [spec: webdav/org-scope]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1171,7 +1171,7 @@ describe('WebDAV API', () => { expect(root.status).toBe(405) }) - it('COPY recursively copies collections and rejects copying into own descendant', async () => { + it('COPY recursively copies collections and rejects copying into own descendant [spec: webdav/copy-recursive]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1269,7 +1269,7 @@ describe('WebDAV API', () => { expect(partialRows).toEqual([]) }) - it('MOVE keeps collection descendant paths consistent and rejects descendant moves', async () => { + it('MOVE keeps collection descendant paths consistent and rejects descendant moves [spec: webdav/move-descendants]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1302,7 +1302,7 @@ describe('WebDAV API', () => { expect(descendant.status).toBe(403) }) - it('write methods enforce WebDAV If and lock preconditions before mutations', async () => { + it('write methods enforce WebDAV If and lock preconditions before mutations [spec: webdav/lock-preconditions]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1599,7 +1599,7 @@ describe('WebDAV API', () => { expect(notMatched.status).toBe(204) }) - it('LOCK and UNLOCK expose Class 2 state and enforce write tokens', async () => { + it('LOCK and UNLOCK expose Class 2 state and enforce write tokens [spec: webdav/lock-unlock]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1900,7 +1900,7 @@ describe('WebDAV API', () => { expect(missingDelete.status).toBe(404) }) - it('MOVE honors Overwrite header for existing destinations', async () => { + it('MOVE honors Overwrite header for existing destinations [spec: webdav/move-overwrite]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -1988,7 +1988,7 @@ describe('WebDAV API', () => { expect(collectionReplacement.status).toBe(204) }) - it('COPY rolls back quota reservation when storage copy fails', async () => { + it('COPY rolls back quota reservation when storage copy fails [spec: webdav/copy-rollback]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) await seedStorage(db) @@ -2007,7 +2007,7 @@ describe('WebDAV API', () => { expect(rows[0]?.used).toBe(0) }) - it('rejects traversal, empty segments, and encoded path separators', async () => { + it('rejects traversal, empty segments, and encoded path separators [spec: webdav/path-validation]', async () => { const { app, db, auth } = await createTestApp() await authedHeaders(app) const workspace = await org(db) diff --git a/server/routes/webdav.ts b/server/http/webdav.ts similarity index 80% rename from server/routes/webdav.ts rename to server/http/webdav.ts index b06abc46..038f2e98 100644 --- a/server/routes/webdav.ts +++ b/server/http/webdav.ts @@ -1,42 +1,8 @@ -import { and, eq, like, or } from 'drizzle-orm' import type { Context } from 'hono' import { Hono } from 'hono' import { ApiKeyTemplate } from '../../shared/api-key-templates' import { DirType, ObjectStatus } from '../../shared/constants' -import { user } from '../db/auth-schema' -import { matters } from '../db/schema' -import { mapDomainError } from '../lib/http-errors' -import type { Env } from '../middleware/platform' -import { ApiKeyRateLimitError, verifyApiKeyForPermission } from '../services/api-keys' -import { refundTraffic } from '../services/effective-quota' -import { copyMatter, createMatter, trashMatter, updateMatter } from '../services/matter' -import { buildObjectKey, fileExt } from '../services/path-template' -import { S3Service } from '../services/s3' -import { getStorage, type Storage as S3Storage, selectStorage } from '../services/storage' -import { reconcileStorageUsage, withStorageUsageReservation } from '../services/storage-usage' -import { - ensureFolder, - joinMatterPath, - listChildren, - listUserWorkspaces, - resolveExistingWebDavPath, - resolveWebDavPath, - WebDavPathError, - type WebDavTarget, -} from '../services/webdav-path' -import { - activeLocks, - activeLocksForResources, - applyDeadPropertyUpdate, - conflictingLocks, - copyDeadProperties, - createLock, - deleteWebDavState, - listDeadPropertiesForResources, - moveWebDavState, - refreshLock, - removeLock, -} from '../services/webdav-state' +import { joinMatterPath } from '../domain/webdav' import { type DavEntry, davEtag, @@ -51,10 +17,21 @@ import { proppatchMultistatus, workspaceEntry, xmlResponse, -} from '../services/webdav-xml' +} from '../domain/webdav-xml' +import { mapDomainError } from '../lib/http-errors' +import { buildObjectKey, fileExt } from '../lib/path-template' +import type { Env } from '../middleware/platform' +import { + ApiKeyRateLimitError, + type MatterRepo, + type S3Gateway, + type StorageRecord as S3Storage, + WebDavPathError, + type WebDavTarget, +} from '../usecases/ports' +import { withStorageUsageReservation } from '../usecases/storage-usage' import { consumeAndReportDownloadTraffic } from './traffic-metering-utils' -const s3 = new S3Service() const READ_METHODS = new Set(['OPTIONS', 'PROPFIND', 'GET', 'HEAD']) const WRITE_METHODS = new Set(['PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'PROPPATCH', 'LOCK', 'UNLOCK']) const WEBDAV_RESOURCE = 'webdav' @@ -73,16 +50,18 @@ async function requireWebDavApiKey(c: DavContext): Promise { try { const db = c.get('platform').db - const key = await verifyApiKeyForPermission( - c.get('auth'), - db, - credentials.password, - WEBDAV_RESOURCE, - action, - ApiKeyTemplate.WEBDAV, - ) + const key = await c + .get('deps') + .apiKeys.verifyApiKeyForPermission( + c.get('auth'), + db, + credentials.password, + WEBDAV_RESOURCE, + action, + ApiKeyTemplate.WEBDAV, + ) if (!key) return unauthorized() - if (!(await usernameMatches(db, key.referenceId, credentials.username))) return unauthorized() + if (!(await c.get('deps').userAdmin.matchesUsername(key.referenceId, credentials.username))) return unauthorized() c.set('userId', key.referenceId) return { userId: key.referenceId } } catch (error) { @@ -122,21 +101,6 @@ function parseBasicAuth(header: string | null): { username: string; password: st return { username, password } } -async function usernameMatches( - db: Env['Variables']['platform']['db'], - userId: string, - username: string, -): Promise { - 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 -} - function davPath(c: DavContext): string { return normalizeDavMountPath(new URL(c.req.url).pathname) } @@ -160,14 +124,15 @@ function destinationPath(c: DavContext): string | Response { } async function ensureParentCollection( - db: Env['Variables']['platform']['db'], + c: DavContext, userId: string, workspaceSlug: string, parent: string, ): Promise { if (!parent) return - const target = await resolveWebDavPath(db, userId, `/dav/${workspaceSlug}/${parent}`) - ensureFolder(target) + const target = await c.get('deps').webdavPath.resolveWebDavPath(userId, `/dav/${workspaceSlug}/${parent}`) + if (!target.matter) throw new WebDavPathError('Parent collection not found', 409) + if (target.matter.dirtype === DirType.FILE) throw new WebDavPathError('Not a collection', 405) } function requireWorkspace(target: WebDavTarget) { @@ -328,6 +293,7 @@ function finalMultipartBoundary(boundary: string): Uint8Array { } function multipartRangeBody( + s3: S3Gateway, storage: S3Storage, matter: NonNullable, boundary: string, @@ -339,7 +305,7 @@ function multipartRangeBody( try { for (const range of ranges) { controller.enqueue(multipartRangeHeader(boundary, matter.type, range, size)) - await enqueueObjectRange(controller, storage, matter.object, range) + await enqueueObjectRange(s3, controller, storage, matter.object, range) controller.enqueue(new Uint8Array([13, 10])) } controller.enqueue(finalMultipartBoundary(boundary)) @@ -352,6 +318,7 @@ function multipartRangeBody( } async function enqueueObjectRange( + s3: S3Gateway, controller: ReadableStreamDefaultController, storage: S3Storage, object: string, @@ -505,7 +472,7 @@ function lockRefreshToken(c: DavContext): string | Response | null { async function lockPrecondition(c: DavContext, target: WebDavTarget): Promise { const workspace = requireWorkspace(target) - const locks = await activeLocks(c.get('platform').db, workspace.id, resourcePath(target)) + const locks = await c.get('deps').webdavState.activeLocks(workspace.id, resourcePath(target)) if (locks.length === 0) return null const tokens = submittedLockTokens(c) if (locks.every((lock) => tokens.has(lock.token))) return null @@ -532,7 +499,7 @@ async function evaluateIfHeader( if (!target) continue const workspace = target.workspace const etag = target.matter ? matterEtag(target.matter) : null - const locks = workspace ? await activeLocks(c.get('platform').db, workspace.id, resourcePath(target)) : [] + const locks = workspace ? await c.get('deps').webdavState.activeLocks(workspace.id, resourcePath(target)) : [] const lockTokens = new Set(locks.map((lock) => lock.token)) const list = clause[2] const conditions = [...list.matchAll(/(Not\s+)?(?:\[([^\]]+)\]|<([^>]+)>)/gi)] @@ -556,7 +523,7 @@ async function ifTaggedTarget(c: DavContext, auth: DavAuth, tag: string): Promis try { const url = new URL(tag, c.req.url) if (url.origin !== new URL(c.req.url).origin) return null - return await resolveWebDavPath(c.get('platform').db, auth.userId, normalizeDavMountPath(url.pathname)) + return await c.get('deps').webdavPath.resolveWebDavPath(auth.userId, normalizeDavMountPath(url.pathname)) } catch { return null } @@ -580,12 +547,12 @@ async function davEntries(c: DavContext, targets: WebDavTarget[]): Promise[], -): Promise { - const now = new Date() - for (const row of rows) { - await db - .update(matters) - .set({ status: ObjectStatus.ACTIVE, trashedAt: null, updatedAt: now }) - .where(and(eq(matters.id, row.id), eq(matters.orgId, row.orgId))) - } -} - const app = new Hono().on( ['OPTIONS', 'PROPFIND', 'PROPPATCH', 'GET', 'HEAD', 'PUT', 'DELETE', 'MKCOL', 'MOVE', 'COPY', 'LOCK', 'UNLOCK'], ['/', '/*'], @@ -668,9 +613,9 @@ const app = new Hono().on( ) async function propfind(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db + const webdavPath = c.get('deps').webdavPath try { - const target = await resolveWebDavPath(db, auth.userId, davPath(c)) + const target = await webdavPath.resolveWebDavPath(auth.userId, davPath(c)) const depth = c.req.header('Depth') ?? '1' if (depth !== '0' && depth !== '1') { return xmlResponse(errorXml('propfind-finite-depth', 'Depth infinity is not supported for PROPFIND.'), 403) @@ -681,7 +626,7 @@ async function propfind(c: DavContext, auth: DavAuth): Promise { if (target.mountRoot) { targets.push(target) if (depth !== '0') { - for (const workspace of await listUserWorkspaces(db, auth.userId)) { + for (const workspace of await webdavPath.listUserWorkspaces(auth.userId)) { const workspaceTarget = { workspace, mountRoot: false, parent: '', name: '', matter: null } targets.push(workspaceTarget) } @@ -691,7 +636,7 @@ async function propfind(c: DavContext, auth: DavAuth): Promise { const workspace = requireWorkspace(target) targets.push(target) if (depth !== '0') { - for (const matter of await listChildren(db, workspace.id, '')) { + for (const matter of await webdavPath.listChildren(workspace.id, '')) { targets.push({ workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter }) } } @@ -700,7 +645,7 @@ async function propfind(c: DavContext, auth: DavAuth): Promise { targets.push(target) if (depth !== '0' && target.matter.dirtype !== DirType.FILE) { const parent = joinMatterPath(target.matter.parent, target.matter.name) - for (const matter of await listChildren(db, workspace.id, parent)) { + for (const matter of await webdavPath.listChildren(workspace.id, parent)) { targets.push({ workspace, mountRoot: false, parent: matter.parent, name: matter.name, matter }) } } @@ -717,9 +662,8 @@ async function propfind(c: DavContext, auth: DavAuth): Promise { } async function proppatch(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const target = await resolveWebDavPath(db, auth.userId, davPath(c)) + const target = await c.get('deps').webdavPath.resolveWebDavPath(auth.userId, davPath(c)) if (target.name && !target.matter) throw new WebDavPathError('Not found', 404) const workspace = requireWorkspace(target) const locked = await lockPrecondition(c, target) @@ -727,12 +671,9 @@ async function proppatch(c: DavContext, auth: DavAuth): Promise { const ifFailed = await ifHeaderPrecondition(c, auth, target) if (ifFailed) return ifFailed const operations = parseProppatchXml(await c.req.text()) - await applyDeadPropertyUpdate(db, workspace.id, resourcePath(target), operations) + await c.get('deps').webdavState.applyDeadPropertyUpdate(workspace.id, resourcePath(target), operations) if (target.matter) { - await db - .update(matters) - .set({ updatedAt: new Date() }) - .where(and(eq(matters.id, target.matter.id), eq(matters.orgId, workspace.id))) + await c.get('deps').matter.touch(workspace.id, target.matter.id) } const properties = operations.map((operation) => operation.property) return xmlResponse(proppatchMultistatus(targetHref(target), properties), 207) @@ -748,16 +689,15 @@ async function proppatch(c: DavContext, auth: DavAuth): Promise { } async function readFile(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const { matter, workspace } = await resolveExistingWebDavPath(db, auth.userId, davPath(c)) + const { matter, workspace } = await c.get('deps').webdavPath.resolveExistingWebDavPath(auth.userId, davPath(c)) if (!matter) throw new WebDavPathError('Not found', 404) if (!workspace) throw new WebDavPathError('Workspace not found', 404) if (matter.dirtype !== DirType.FILE) return c.text('Cannot read collection as file', 405) const precondition = preconditionResponse(c, matter) if (precondition) return precondition - const storage = await getStorage(db, matter.storageId) + const storage = await c.get('deps').storages.get(matter.storageId) if (!storage) return c.text('Storage not found', 404) const headers = fileHeaders(matter) if (isMountedWebDavRead(c)) { @@ -780,10 +720,10 @@ async function readFile(c: DavContext, auth: DavAuth): Promise { const trafficError = await reserveWebDavTraffic(c, workspace.id, matter.id, storage, size) if (trafficError) return trafficError try { - const body = await s3.getObjectBody(storage, matter.object) + const body = await c.get('deps').s3.getObjectBody(storage, matter.object) return new Response(fixedLengthResponseBody(body, size), { headers }) } catch (e) { - await refundTraffic(db, workspace.id, size) + await c.get('deps').quota.refundTraffic(workspace.id, size) throw e } } @@ -796,7 +736,7 @@ async function readFile(c: DavContext, auth: DavAuth): Promise { if (rangeRequest.ranges.length > 1) { const boundary = `zpan-webdav-${matter.id}` const contentLength = multipartRangeContentLength(boundary, matter.type, rangeRequest.ranges, size) - const body = multipartRangeBody(storage, matter, boundary, rangeRequest.ranges, size) + const body = multipartRangeBody(c.get('deps').s3, storage, matter, boundary, rangeRequest.ranges, size) headers.set('Content-Type', `multipart/byteranges; boundary=${boundary}`) headers.set('Content-Length', String(contentLength)) headers.delete('Content-Range') @@ -807,9 +747,9 @@ async function readFile(c: DavContext, auth: DavAuth): Promise { const contentLength = range.end - range.start + 1 let body: BodyInit try { - body = await s3.getObjectBody(storage, matter.object, `bytes=${range.start}-${range.end}`) + body = await c.get('deps').s3.getObjectBody(storage, matter.object, `bytes=${range.start}-${range.end}`) } catch (e) { - await refundTraffic(db, workspace.id, contentLength) + await c.get('deps').quota.refundTraffic(workspace.id, contentLength) throw e } headers.set('Content-Length', String(contentLength)) @@ -839,9 +779,8 @@ async function reserveWebDavTraffic( } async function putFile(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const target = await resolveWebDavPath(db, auth.userId, davPath(c)) + const target = await c.get('deps').webdavPath.resolveWebDavPath(auth.userId, davPath(c)) const workspace = requireWorkspace(target) if (!target.name) return c.text('Cannot PUT a collection root', 405) if (target.matter && target.matter.dirtype !== DirType.FILE) @@ -852,13 +791,15 @@ async function putFile(c: DavContext, auth: DavAuth): Promise { if (ifFailed) return ifFailed const precondition = target.matter ? preconditionResponse(c, target.matter) : missingPreconditionResponse(c) if (precondition) return precondition - await ensureParentCollection(db, auth.userId, workspace.slug, target.parent) + await ensureParentCollection(c, auth.userId, workspace.slug, target.parent) const contentLength = parseContentLength(c.req.header('Content-Length')) if (contentLength instanceof Response) return contentLength const body = contentLength === 0 ? new Uint8Array() : c.req.raw.body if (!body) return c.text('Request body required', 400) - const storage = target.matter ? await getStorage(db, target.matter.storageId) : await selectStorage(db, 'private') + const storage = target.matter + ? await c.get('deps').storages.get(target.matter.storageId) + : await c.get('deps').storages.select('private') if (!storage) return c.text('Storage not found', 404) const objectKey = target.matter?.object && contentLength !== null @@ -871,9 +812,10 @@ async function putFile(c: DavContext, auth: DavAuth): Promise { try { return await withStorageUsageReservation( - db, + c.get('deps'), { orgId: workspace.id, storageId: storage.id, bytes: Math.max(0, knownSizeDelta) }, async (ctx) => { + const s3 = c.get('deps').s3 const uploadedSize = await s3.putObject(storage, objectKey, body, contentType, contentLength ?? undefined) const sizeDelta = target.matter ? uploadedSize - (target.matter.size ?? 0) : uploadedSize @@ -883,11 +825,12 @@ async function putFile(c: DavContext, auth: DavAuth): Promise { if (contentLength === null && sizeDelta > 0) { return withStorageUsageReservation( - db, + c.get('deps'), { orgId: workspace.id, storageId: storage.id, bytes: sizeDelta }, async () => { return persistWebDavUpload( - db, + s3, + c.get('deps').matter, workspace.id, auth.userId, target, @@ -901,7 +844,8 @@ async function putFile(c: DavContext, auth: DavAuth): Promise { } const response = await persistWebDavUpload( - db, + s3, + c.get('deps').matter, workspace.id, auth.userId, target, @@ -910,7 +854,7 @@ async function putFile(c: DavContext, auth: DavAuth): Promise { contentType, uploadedSize, ) - if (sizeDelta < 0) await reconcileStorageUsage(db, workspace.id, [storage.id]) + if (sizeDelta < 0) await c.get('deps').storageUsage.reconcile(workspace.id, [storage.id]) return response }, ) @@ -925,7 +869,8 @@ async function putFile(c: DavContext, auth: DavAuth): Promise { } async function persistWebDavUpload( - db: Env['Variables']['platform']['db'], + s3: S3Gateway, + matterRepo: MatterRepo, orgId: string, userId: string, target: WebDavTarget, @@ -935,16 +880,12 @@ async function persistWebDavUpload( uploadedSize: number, ): Promise { if (target.matter) { - const now = new Date() - await db - .update(matters) - .set({ type: contentType, size: uploadedSize, object: objectKey, updatedAt: now }) - .where(and(eq(matters.id, target.matter.id), eq(matters.orgId, orgId))) + await matterRepo.applyUpload(orgId, target.matter.id, { type: contentType, size: uploadedSize, object: objectKey }) if (objectKey !== target.matter.object) await s3.deleteObject(storage, target.matter.object) return new Response(null, { status: 204 }) } - await createMatter(db, { + await matterRepo.create({ orgId, userId, name: target.name, @@ -960,9 +901,8 @@ async function persistWebDavUpload( } async function makeCollection(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const target = await resolveWebDavPath(db, auth.userId, davPath(c)) + const target = await c.get('deps').webdavPath.resolveWebDavPath(auth.userId, davPath(c)) const workspace = requireWorkspace(target) if (!target.name) return c.text('Cannot create collection root', 405) if (target.matter) return c.text('Already exists', 405) @@ -974,9 +914,9 @@ async function makeCollection(c: DavContext, auth: DavAuth): Promise { if (locked) return locked const ifFailed = await ifHeaderPrecondition(c, auth, target) if (ifFailed) return ifFailed - await ensureParentCollection(db, auth.userId, workspace.slug, target.parent) - const storage = await selectStorage(db, 'private') - await createMatter(db, { + await ensureParentCollection(c, auth.userId, workspace.slug, target.parent) + const storage = await c.get('deps').storages.select('private') + await c.get('deps').matter.create({ orgId: workspace.id, userId: auth.userId, name: target.name, @@ -995,9 +935,8 @@ async function makeCollection(c: DavContext, auth: DavAuth): Promise { } async function deleteMatter(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const target = await resolveExistingWebDavPath(db, auth.userId, davPath(c)) + const target = await c.get('deps').webdavPath.resolveExistingWebDavPath(auth.userId, davPath(c)) const workspace = requireWorkspace(target) const matter = target.matter if (!matter) throw new WebDavPathError('Not found', 404) @@ -1005,8 +944,8 @@ async function deleteMatter(c: DavContext, auth: DavAuth): Promise { if (locked) return locked const ifFailed = await ifHeaderPrecondition(c, auth, target) if (ifFailed) return ifFailed - await deleteWebDavState(db, workspace.id, resourcePath(target)) - await trashMatter(db, workspace.id, matter.id, auth.userId) + await c.get('deps').webdavState.deleteWebDavState(workspace.id, resourcePath(target)) + await c.get('deps').matter.trash(workspace.id, matter.id, auth.userId) return new Response(null, { status: 204 }) } catch (e) { return davError(c, e) @@ -1014,9 +953,8 @@ async function deleteMatter(c: DavContext, auth: DavAuth): Promise { } async function moveMatter(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const source = await resolveExistingWebDavPath(db, auth.userId, davPath(c)) + const source = await c.get('deps').webdavPath.resolveExistingWebDavPath(auth.userId, davPath(c)) const sourceWorkspace = requireWorkspace(source) if (!source.matter) throw new WebDavPathError('Not found', 404) const locked = await lockPrecondition(c, source) @@ -1027,7 +965,7 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise { if (precondition) return precondition const destination = destinationPath(c) if (destination instanceof Response) return destination - const target = await resolveWebDavPath(db, auth.userId, destination) + const target = await c.get('deps').webdavPath.resolveWebDavPath(auth.userId, destination) const targetWorkspace = requireWorkspace(target) if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace MOVE is not supported', 403) if (!target.name) return c.text('Cannot move to collection root', 405) @@ -1045,21 +983,17 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise { if (target.matter.id === source.matter.id) return new Response(null, { status: 204 }) if (!overwriteAllowed(c)) return c.text('Already exists', 412) } - await ensureParentCollection(db, auth.userId, targetWorkspace.slug, target.parent) + await ensureParentCollection(c, auth.userId, targetWorkspace.slug, target.parent) const oldPath = resourcePath(source) const newPath = joinMatterPath(target.parent, target.name) if (target.matter) { - await deleteWebDavState(db, targetWorkspace.id, resourcePath(target)) - await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId) + await c.get('deps').webdavState.deleteWebDavState(targetWorkspace.id, resourcePath(target)) + await c.get('deps').matter.trash(targetWorkspace.id, target.matter.id, auth.userId) } - await updateMatter( - db, - source.matter.id, - sourceWorkspace.id, - { name: target.name, parent: target.parent }, - auth.userId, - ) - await moveWebDavState(db, sourceWorkspace.id, oldPath, newPath) + await c + .get('deps') + .matter.update(source.matter.id, sourceWorkspace.id, { name: target.name, parent: target.parent }, auth.userId) + await c.get('deps').webdavState.moveWebDavState(sourceWorkspace.id, oldPath, newPath) return new Response(null, { status: replacingTarget ? 204 : 201 }) } catch (e) { return davError(c, e) @@ -1067,9 +1001,8 @@ async function moveMatter(c: DavContext, auth: DavAuth): Promise { } async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const source = await resolveExistingWebDavPath(db, auth.userId, davPath(c)) + const source = await c.get('deps').webdavPath.resolveExistingWebDavPath(auth.userId, davPath(c)) const sourceWorkspace = requireWorkspace(source) if (!source.matter) throw new WebDavPathError('Not found', 404) const sourceMatter = source.matter @@ -1079,7 +1012,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise if (precondition) return precondition const destination = destinationPath(c) if (destination instanceof Response) return destination - const target = await resolveWebDavPath(db, auth.userId, destination) + const target = await c.get('deps').webdavPath.resolveWebDavPath(auth.userId, destination) const targetWorkspace = requireWorkspace(target) if (sourceWorkspace.id !== targetWorkspace.id) return c.text('Cross-workspace COPY is not supported', 403) if (!target.name) return c.text('Cannot copy to collection root', 405) @@ -1092,7 +1025,7 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise if (targetLocked) return targetLocked if (target.matter && !overwriteAllowed(c)) return c.text('Already exists', 412) const replacingTarget = Boolean(target.matter) - await ensureParentCollection(db, auth.userId, targetWorkspace.slug, target.parent) + await ensureParentCollection(c, auth.userId, targetWorkspace.slug, target.parent) if (sourceMatter.dirtype !== DirType.FILE) { return copyCollection(c, auth, source, target, replacingTarget) @@ -1100,29 +1033,38 @@ async function copyMatterRoute(c: DavContext, auth: DavAuth): Promise let newObject = '' try { - const storage = sourceMatter.object ? await getStorage(db, sourceMatter.storageId) : null + const storage = sourceMatter.object ? await c.get('deps').storages.get(sourceMatter.storageId) : null if (sourceMatter.object && !storage) return c.text('Storage not found', 404) const bytes = sourceMatter.size ?? 0 return await withStorageUsageReservation( - db, + c.get('deps'), { orgId: sourceWorkspace.id, storageId: sourceMatter.storageId, bytes }, async (ctx) => { if (sourceMatter.object && storage) { + const s3 = c.get('deps').s3 newObject = buildObjectKey({ uid: auth.userId, orgId: sourceWorkspace.id, rawExt: fileExt(target.name) }) await s3.copyObject(storage, sourceMatter.object, storage, newObject) ctx.onRollback(() => s3.deleteObject(storage, newObject)) } if (target.matter) { - await deleteWebDavState(db, targetWorkspace.id, resourcePath(target)) - await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId) + await c.get('deps').webdavState.deleteWebDavState(targetWorkspace.id, resourcePath(target)) + await c.get('deps').matter.trash(targetWorkspace.id, target.matter.id, auth.userId) } - const copy = await copyMatter(db, { ...sourceMatter, name: target.name }, target.parent, newObject, { - onConflict: 'fail', - userId: auth.userId, - }) - await copyDeadProperties(db, sourceWorkspace.id, resourcePath(source), joinMatterPath(copy.parent, copy.name)) + const copy = await c + .get('deps') + .matter.copy({ ...sourceMatter, name: target.name }, target.parent, newObject, { + onConflict: 'fail', + userId: auth.userId, + }) + await c + .get('deps') + .webdavState.copyDeadProperties( + sourceWorkspace.id, + resourcePath(source), + joinMatterPath(copy.parent, copy.name), + ) c.header('Location', matterLocation(c.req.url, targetWorkspace.slug, joinMatterPath(copy.parent, copy.name))) return c.body(null, replacingTarget ? 204 : 201) }, @@ -1144,7 +1086,6 @@ async function copyCollection( target: WebDavTarget, replacingTarget: boolean, ): Promise { - const db = c.get('platform').db const sourceWorkspace = requireWorkspace(source) const targetWorkspace = requireWorkspace(target) if (!source.matter) throw new WebDavPathError('Not found', 404) @@ -1153,10 +1094,12 @@ async function copyCollection( const depth = c.req.header('Depth') ?? 'infinity' if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400) + const webdavPath = c.get('deps').webdavPath + const webdavState = c.get('deps').webdavState const sourceRoot = joinMatterPath(sourceMatter.parent, sourceMatter.name) const targetRoot = joinMatterPath(target.parent, target.name) - const children = await listChildren(db, sourceWorkspace.id, sourceRoot) - const descendants = await listDescendants(db, sourceWorkspace.id, sourceRoot) + const children = await webdavPath.listChildren(sourceWorkspace.id, sourceRoot) + const descendants = await c.get('deps').matter.listActiveDescendants(sourceWorkspace.id, sourceRoot) const ordered = depth === 'infinity' ? [...children, ...descendants].sort((a, b) => a.parent.length - b.parent.length) : [] const preparedCopies: Array<{ item: (typeof ordered)[number]; targetParent: string; objectKey: string }> = [] @@ -1165,8 +1108,8 @@ async function copyCollection( target.matter && target.matter.dirtype !== DirType.FILE ? [ target.matter, - ...(await listChildren(db, targetWorkspace.id, resourcePath(target))), - ...(await listDescendants(db, targetWorkspace.id, resourcePath(target))), + ...(await webdavPath.listChildren(targetWorkspace.id, resourcePath(target))), + ...(await c.get('deps').matter.listActiveDescendants(targetWorkspace.id, resourcePath(target))), ] : target.matter ? [target.matter] @@ -1176,14 +1119,15 @@ async function copyCollection( .map((item) => ({ orgId: targetWorkspace.id, storageId: item.storageId, bytes: item.size ?? 0 })) try { - return await withStorageUsageReservation(db, reservationInputs, async (ctx) => { + return await withStorageUsageReservation(c.get('deps'), reservationInputs, async (ctx) => { for (const item of ordered) { const targetParent = item.parent === sourceRoot ? targetRoot : `${targetRoot}${item.parent.slice(sourceRoot.length)}` let objectKey = '' if (item.dirtype === DirType.FILE && item.object) { - const storage = await getStorage(db, item.storageId) + const storage = await c.get('deps').storages.get(item.storageId) if (!storage) return c.text('Storage not found', 404) + const s3 = c.get('deps').s3 objectKey = buildObjectKey({ uid: auth.userId, orgId: targetWorkspace.id, rawExt: fileExt(item.name) }) await s3.copyObject(storage, item.object, storage, objectKey) ctx.onRollback(() => s3.deleteObject(storage, objectKey)) @@ -1192,25 +1136,28 @@ async function copyCollection( } if (target.matter) { - await deleteWebDavState(db, targetWorkspace.id, resourcePath(target)) - await trashMatter(db, targetWorkspace.id, target.matter.id, auth.userId) + await webdavState.deleteWebDavState(targetWorkspace.id, resourcePath(target)) + await c.get('deps').matter.trash(targetWorkspace.id, target.matter.id, auth.userId) } - const rootCopy = await copyMatter(db, { ...sourceMatter, name: target.name }, target.parent, '', { + const rootCopy = await c.get('deps').matter.copy({ ...sourceMatter, name: target.name }, target.parent, '', { onConflict: 'fail', userId: auth.userId, }) createdIds.push(rootCopy.id) - await copyDeadProperties(db, sourceWorkspace.id, sourceRoot, joinMatterPath(rootCopy.parent, rootCopy.name)) + await webdavState.copyDeadProperties( + sourceWorkspace.id, + sourceRoot, + joinMatterPath(rootCopy.parent, rootCopy.name), + ) for (const prepared of preparedCopies) { - const copy = await copyMatter(db, prepared.item, prepared.targetParent, prepared.objectKey, { + const copy = await c.get('deps').matter.copy(prepared.item, prepared.targetParent, prepared.objectKey, { onConflict: 'fail', userId: auth.userId, }) createdIds.push(copy.id) - await copyDeadProperties( - db, + await webdavState.copyDeadProperties( sourceWorkspace.id, joinMatterPath(prepared.item.parent, prepared.item.name), joinMatterPath(copy.parent, copy.name), @@ -1225,13 +1172,14 @@ async function copyCollection( }) } catch (e) { if (createdIds.length > 0) { - await db - .update(matters) - .set({ status: ObjectStatus.TRASHED, trashedAt: Date.now(), updatedAt: new Date() }) - .where(and(eq(matters.orgId, targetWorkspace.id), or(...createdIds.map((id) => eq(matters.id, id))))) - await deleteWebDavState(db, targetWorkspace.id, targetRoot) + await c.get('deps').matter.trashByIds(targetWorkspace.id, createdIds) + await webdavState.deleteWebDavState(targetWorkspace.id, targetRoot) } - if (targetRows.length > 0) await restoreActiveMatterRows(db, targetRows) + if (targetRows.length > 0) + await c.get('deps').matter.restoreActiveByIds( + targetWorkspace.id, + targetRows.map((r) => r.id), + ) const mapped = mapDomainError(e) if (mapped) return c.text(mapped.message, mapped.status) throw e @@ -1239,17 +1187,16 @@ async function copyCollection( } async function lockMatter(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db + const webdavState = c.get('deps').webdavState try { - const target = await resolveWebDavPath(db, auth.userId, davPath(c)) + const target = await c.get('deps').webdavPath.resolveWebDavPath(auth.userId, davPath(c)) const workspace = requireWorkspace(target) const body = await c.req.text() const existingToken = lockRefreshToken(c) if (existingToken instanceof Response) return existingToken if (existingToken) { if (body.length > 0) return xmlResponse(errorXml('lock-token-submitted'), 400) - const refreshed = await refreshLock( - db, + const refreshed = await webdavState.refreshLock( workspace.id, resourcePath(target), existingToken, @@ -1262,7 +1209,7 @@ async function lockMatter(c: DavContext, auth: DavAuth): Promise { const depth = c.req.header('Depth') ?? 'infinity' if (depth !== '0' && depth !== 'infinity') return xmlResponse(errorXml('bad-depth'), 400) const path = resourcePath(target) - const conflicts = await conflictingLocks(db, workspace.id, path) + const conflicts = await webdavState.conflictingLocks(workspace.id, path) if (conflicts.length > 0) return xmlResponse(errorXml('no-conflicting-lock'), 423) let lockInfo: { owner: string } try { @@ -1272,11 +1219,11 @@ async function lockMatter(c: DavContext, auth: DavAuth): Promise { } const created = !target.matter && Boolean(target.name) if (created) { - await ensureParentCollection(db, auth.userId, workspace.slug, target.parent) - const storage = await selectStorage(db, 'private') + await ensureParentCollection(c, auth.userId, workspace.slug, target.parent) + const storage = await c.get('deps').storages.select('private') const objectKey = buildObjectKey({ uid: auth.userId, orgId: workspace.id, rawExt: fileExt(target.name) }) - await s3.putObject(storage, objectKey, new Uint8Array(), 'application/octet-stream') - target.matter = await createMatter(db, { + await c.get('deps').s3.putObject(storage, objectKey, new Uint8Array(), 'application/octet-stream') + target.matter = await c.get('deps').matter.create({ orgId: workspace.id, userId: auth.userId, name: target.name, @@ -1289,7 +1236,7 @@ async function lockMatter(c: DavContext, auth: DavAuth): Promise { status: ObjectStatus.ACTIVE, }) } - const lock = await createLock(db, { + const lock = await webdavState.createLock({ orgId: workspace.id, resourcePath: path, owner: lockInfo.owner, @@ -1303,13 +1250,12 @@ async function lockMatter(c: DavContext, auth: DavAuth): Promise { } async function unlockMatter(c: DavContext, auth: DavAuth): Promise { - const db = c.get('platform').db try { - const target = await resolveExistingWebDavPath(db, auth.userId, davPath(c)) + const target = await c.get('deps').webdavPath.resolveExistingWebDavPath(auth.userId, davPath(c)) const workspace = requireWorkspace(target) const token = lockTokenHeader(c) if (!token) return xmlResponse(errorXml('lock-token-submitted'), 400) - const removed = await removeLock(db, workspace.id, resourcePath(target), token) + const removed = await c.get('deps').webdavState.removeLock(workspace.id, resourcePath(target), token) if (!removed) return xmlResponse(errorXml('lock-token-matches-request-uri'), 409) return new Response(null, { status: 204 }) } catch (e) { diff --git a/server/lib/http-errors.test.ts b/server/lib/http-errors.test.ts index bd223db2..657fee73 100644 --- a/server/lib/http-errors.test.ts +++ b/server/lib/http-errors.test.ts @@ -1,7 +1,5 @@ import { describe, expect, it } from 'vitest' -import { NameConflictError } from '../services/matter-name-conflict' -import { StorageQuotaExceededError } from '../services/storage-usage' -import { WebDavPathError } from '../services/webdav-path' +import { NameConflictError, StorageQuotaExceededError, WebDavPathError } from '../usecases/ports' import { mapDomainError } from './http-errors' describe('mapDomainError', () => { diff --git a/server/lib/http-errors.ts b/server/lib/http-errors.ts index 12fe1f92..a1bb6f92 100644 --- a/server/lib/http-errors.ts +++ b/server/lib/http-errors.ts @@ -1,7 +1,5 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status' -import { NameConflictError } from '../services/matter-name-conflict' -import { StorageQuotaExceededError } from '../services/storage-usage' -import { WebDavPathError } from '../services/webdav-path' +import { NameConflictError, StorageQuotaExceededError, WebDavPathError } from '../usecases/ports' export interface DomainErrorMapping { status: ContentfulStatusCode diff --git a/server/services/path-template.test.ts b/server/lib/path-template.test.ts similarity index 100% rename from server/services/path-template.test.ts rename to server/lib/path-template.test.ts diff --git a/server/services/path-template.ts b/server/lib/path-template.ts similarity index 100% rename from server/services/path-template.ts rename to server/lib/path-template.ts diff --git a/server/licensing/cloud-event-token.ts b/server/licensing/cloud-event-token.ts deleted file mode 100644 index 430483d1..00000000 --- a/server/licensing/cloud-event-token.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { verify } from 'paseto-ts/v4' -import { z } from 'zod' -import { getTrustedPublicKeys } from './public-keys' -import { trustedIssuerFromCloudUrl } from './verify' - -const CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS = 5 * 60 - -const cloudEventTokenSchema = z.object({ - type: z.literal('commerce.fulfillment.token'), - purpose: z.literal('store.delivery'), - issuer: z.string().min(1), - audience: z.string().min(1), - boundLicenseId: z.string().min(1), - eventId: z.string().min(1), - payloadHash: z - .string() - .regex(/^[0-9a-f]{64}$/i) - .optional(), - issuedAt: z.number().int(), - notBefore: z.number().int().optional(), - expiresAt: z.number().int(), -}) - -export type CloudEventToken = z.infer - -export interface VerifyCloudEventTokenOptions { - cloudBaseUrl: string - instanceId: string - boundLicenseId: string - payloadHash: string -} - -export function verifyCloudEventToken(token: string, options: VerifyCloudEventTokenOptions): CloudEventToken | null { - for (const key of getTrustedPublicKeys()) { - const event = tryVerifyCloudEventToken(token, key, options) - if (event) return event - } - return null -} - -function tryVerifyCloudEventToken( - token: string, - publicKey: string, - options: VerifyCloudEventTokenOptions, -): CloudEventToken | null { - try { - const { payload } = verify>(publicKey, token, { validatePayload: false }) - const parsed = cloudEventTokenSchema.safeParse(payload) - if (!parsed.success) return null - - const event = parsed.data - const now = Math.floor(Date.now() / 1000) - if (event.issuer !== trustedIssuerFromCloudUrl(options.cloudBaseUrl)) return null - if (event.audience !== options.instanceId && event.audience !== options.boundLicenseId) return null - if (event.boundLicenseId !== options.boundLicenseId) return null - if (event.payloadHash && event.payloadHash !== options.payloadHash) return null - if (event.issuedAt > now) return null - if (event.notBefore && event.notBefore > now) return null - if (event.expiresAt <= now) return null - if (event.expiresAt - event.issuedAt > CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS) return null - - return event - } catch { - return null - } -} diff --git a/server/licensing/has-feature.ts b/server/licensing/has-feature.ts deleted file mode 100644 index c0766bef..00000000 --- a/server/licensing/has-feature.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { PRO_GATE_KEYS } from '../../shared/feature-registry' -import type { BindingState, LicenseFeature } from '../../shared/types' -import type { Database } from '../platform/interface' -import { loadLicenseState } from './license-state' -import { verifyCertificate } from './verify' - -export interface BindingStateOptions { - currentHost?: string | null - cloudBaseUrl?: string | null -} - -export async function loadBindingState(db: Database, options: BindingStateOptions = {}): Promise { - const state = await loadLicenseState(db) - if (!state.refreshToken) return { bound: false } - - const result: BindingState = { - bound: true, - active: false, - account_email: state.cloudAccountEmail ?? undefined, - last_refresh_at: state.lastRefreshAt ?? undefined, - last_refresh_error: state.lastRefreshError ?? undefined, - } - - if (state.cachedCert && state.instanceId) { - const assertion = verifyCertificate(state.cachedCert, { - instanceId: state.instanceId, - currentHost: options.currentHost, - cloudBaseUrl: options.cloudBaseUrl, - }) - if (assertion) { - result.active = true - result.edition = assertion.edition - result.features = effectiveFeatures(assertion.edition) - result.license_id = assertion.licenseId - result.license_valid_until = assertion.licenseValidUntil - result.certificate_expires_at = assertion.expiresAt - } - } - - return result -} - -export function hasFeature(feature: LicenseFeature, state: BindingState | null): boolean { - return Boolean(feature && state?.bound && state.active && effectiveFeatures(state.edition).includes(feature)) -} - -const BUSINESS_ONLY_FEATURES = new Set(['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 [] -} diff --git a/server/licensing/instance-id.ts b/server/licensing/instance-id.ts deleted file mode 100644 index 2b20ff25..00000000 --- a/server/licensing/instance-id.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { eq } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { systemOptions } from '../db/schema' -import type { Database } from '../platform/interface' - -const INSTANCE_ID_KEY = 'instance_id' - -export async function getOrCreateInstanceId(db: Database): Promise { - 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 -} diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 3ba8f2d2..8e2fb23c 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,8 +1,5 @@ -import { sql } from 'drizzle-orm' import { createMiddleware } from 'hono/factory' -import { ApiKeyRateLimitError, isOrgApiKey, verifyApiKey } from '../services/api-keys' -import { resolveDownloaderToken, resolveTaskUploadToken } from '../services/download-tokens' -import { findPersonalOrg, getMemberRole, isPersonalOrg } from '../services/org' +import { ApiKeyRateLimitError } from '../usecases/ports' import type { Env } from './platform' // 'member' is the better-auth schema default; map it to viewer level so @@ -24,7 +21,8 @@ export const authMiddleware = createMiddleware(async (c, next) => { if (authHeader?.startsWith('Bearer ')) { const token = authHeader.slice('Bearer '.length).trim() const platform = c.get('platform') - const taskUpload = await resolveTaskUploadToken(platform.db, platform, token) + const deps = c.get('deps') + const taskUpload = await deps.downloadTokens.resolveTaskUploadToken(platform.db, platform, token) if (taskUpload) { c.set('principal', { ...taskUpload, kind: 'download-task-upload', authMethod: 'bearer' }) c.set('userId', null) @@ -33,7 +31,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { await next() return } - const downloader = await resolveDownloaderToken(platform, token) + const downloader = await deps.downloadTokens.resolveDownloaderToken(platform, token) if (downloader) { c.set('principal', { kind: 'downloader', downloaderId: downloader.downloaderId, authMethod: 'bearer' }) c.set('userId', null) @@ -42,9 +40,9 @@ export const authMiddleware = createMiddleware(async (c, next) => { await next() return } - let apiKey: Awaited> + let apiKey: Awaited> try { - apiKey = await verifyApiKey(c.get('auth'), platform.db, token) + apiKey = await deps.apiKeys.verifyApiKey(c.get('auth'), platform.db, token) } catch (error) { if (error instanceof ApiKeyRateLimitError) { const res = c.json({ error: error.message }, 429) @@ -55,8 +53,8 @@ export const authMiddleware = createMiddleware(async (c, next) => { throw error } if (apiKey) { - const orgId = isOrgApiKey(apiKey.configId) ? apiKey.referenceId : null - const userId = isOrgApiKey(apiKey.configId) ? null : apiKey.referenceId + const orgId = deps.apiKeys.isOrgApiKey(apiKey.configId) ? apiKey.referenceId : null + const userId = deps.apiKeys.isOrgApiKey(apiKey.configId) ? null : apiKey.referenceId c.set('principal', { kind: 'api-key', keyId: apiKey.id, @@ -78,9 +76,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { const result = (await auth.api.getSession({ headers: c.req.raw.headers })) as SessionWithPlugins | null if (result?.user?.id) { - const db = c.get('platform').db - const rows = await db.all<{ banned: number }>(sql`SELECT banned FROM user WHERE id = ${result.user.id}`) - if (rows[0]?.banned) { + if (await c.get('deps').userAdmin.isBanned(result.user.id)) { return c.json({ error: 'Account disabled' }, 403) } } @@ -89,7 +85,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { c.set('userRole', result?.user?.role ?? null) if (result?.user?.id) { - const orgId = result.session?.activeOrganizationId ?? (await findPersonalOrg(c.get('platform').db, result.user.id)) + const orgId = result.session?.activeOrganizationId ?? (await c.get('deps').org.findPersonalOrg(result.user.id)) c.set('orgId', orgId) c.set('principal', { kind: 'user', @@ -143,12 +139,10 @@ export function requireTeamRole(minRole: 'viewer' | 'editor' | 'owner') { return c.json({ error: 'Unauthorized' }, 401) } - const db = c.get('platform').db - // Query member role first — avoids an extra DB round trip for the common case. // Personal org owners always have a member row (guaranteed by findPersonalOrg), // so isPersonalOrg is only needed as a fallback when no member row exists. - const role = await getMemberRole(db, orgId, userId) + const role = await c.get('deps').org.getMemberRole(orgId, userId) if (role !== null) { const userLevel = ROLE_LEVELS[role] ?? 0 if (userLevel < ROLE_LEVELS[minRole]) { @@ -159,7 +153,7 @@ export function requireTeamRole(minRole: 'viewer' | 'editor' | 'owner') { } // No member row — could be a personal org accessed without a session refresh. - if (await isPersonalOrg(db, orgId)) { + if (await c.get('deps').org.isPersonalOrg(orgId)) { await next() return } diff --git a/server/middleware/authz.ts b/server/middleware/authz.ts index 370d0ddd..7f9129cf 100644 --- a/server/middleware/authz.ts +++ b/server/middleware/authz.ts @@ -1,6 +1,4 @@ import { createMiddleware } from 'hono/factory' -import { hasApiKeyPermission } from '../services/api-keys' -import { getMemberRole, isPersonalOrg } from '../services/org' import type { Env } from './platform' const ROLE_LEVELS: Record = { @@ -27,7 +25,7 @@ export function requirePermission( if (principal.kind === 'download-task-upload') return c.json({ error: 'Unauthorized' }, 401) if (principal.kind === 'api-key') { - if (!hasApiKeyPermission(principal.permissions, resource, action)) { + if (!c.get('deps').apiKeys.hasApiKeyPermission(principal.permissions, resource, action)) { return c.json({ error: 'Forbidden' }, 403) } return next() @@ -40,13 +38,12 @@ export function requirePermission( const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'Unauthorized' }, 401) - const db = c.get('platform').db - const role = await getMemberRole(db, orgId, userId) + const role = await c.get('deps').org.getMemberRole(orgId, userId) if (role !== null) { if ((ROLE_LEVELS[role] ?? 0) < ROLE_LEVELS[opts.minTeamRole]) return c.json({ error: 'Forbidden' }, 403) return next() } - if (await isPersonalOrg(db, orgId)) return next() + if (await c.get('deps').org.isPersonalOrg(orgId)) return next() return c.json({ error: 'Forbidden' }, 403) }) } diff --git a/server/middleware/image-hosting-domain.cf-test.ts b/server/middleware/image-hosting-domain.cf-test.ts index 58248438..d4fd9a68 100644 --- a/server/middleware/image-hosting-domain.cf-test.ts +++ b/server/middleware/image-hosting-domain.cf-test.ts @@ -1,10 +1,10 @@ import { env } from 'cloudflare:workers' import { sql } from 'drizzle-orm' import { describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3' import { createApp } from '../app' import { createAuth } from '../auth' import { createCloudflarePlatform } from '../platform/cloudflare' -import { S3Service } from '../services/s3' const STORAGE_ID = 'st-cf-domain-test' const MOCK_INLINE_URL = 'https://presigned-inline-cf-domain.example.com/image.png' diff --git a/server/middleware/image-hosting-domain.integration.test.ts b/server/middleware/image-hosting-domain.integration.test.ts index cae23f37..6ec85f49 100644 --- a/server/middleware/image-hosting-domain.integration.test.ts +++ b/server/middleware/image-hosting-domain.integration.test.ts @@ -1,7 +1,7 @@ import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { currentTrafficPeriod } from '../services/effective-quota' -import { S3Service } from '../services/s3' +import { S3Service } from '../adapters/gateways/s3' +import { currentTrafficPeriod } from '../domain/quota' import { authedHeaders, createTestApp } from '../test/setup' const MOCK_INLINE_URL = 'https://presigned-inline.example.com/image.png' diff --git a/server/middleware/image-hosting-domain.ts b/server/middleware/image-hosting-domain.ts index ea637f43..7fe097b8 100644 --- a/server/middleware/image-hosting-domain.ts +++ b/server/middleware/image-hosting-domain.ts @@ -1,12 +1,7 @@ -import { eq } from 'drizzle-orm' import type { Context, Next } from 'hono' -import { imageHostingConfigs } from '../db/schema' +import { PRESIGN_TTL_SECS } from '../http/share-utils' +import { reportTrafficForDownload } from '../http/traffic-metering-utils' import type { Env } from '../middleware/platform' -import { PRESIGN_TTL_SECS, s3 } from '../routes/share-utils' -import { reportTrafficForDownload } from '../routes/traffic-metering-utils' -import { consumeTrafficIfQuotaAllows, refundTraffic } from '../services/effective-quota' -import { getImageByOrgPath, incrementAccessCount, resolveCustomDomain } from '../services/image-hosting' -import { getStorage } from '../services/storage' function stripPort(host: string): string { const lastColon = host.lastIndexOf(':') @@ -45,33 +40,27 @@ function checkReferer(refererAllowlist: string[], refererHeader: string | null): } async function handleImageByPath(c: Context, orgId: string, virtualPath: string): Promise { - const db = c.get('platform').db + const resolved = await c.get('deps').imageHosting.resolveActiveByOrgPath(orgId, virtualPath) + if (!resolved) return c.json({ error: 'Not found' }, 404) - const image = await getImageByOrgPath(db, orgId, virtualPath) - if (!image) return c.json({ error: 'Not found' }, 404) - - const configRows = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1) - if (configRows.length === 0) return c.json({ error: 'Not found' }, 404) - - const config = configRows[0] - const refererAllowlist = config.refererAllowlist ? (JSON.parse(config.refererAllowlist) as string[]) : [] + const { image, refererAllowlist } = resolved const refererHeader = c.req.header('Referer') ?? null if (!checkReferer(refererAllowlist, refererHeader)) { return c.json({ error: 'forbidden referer' }, 403) } - const storage = await getStorage(db, image.storageId) + const storage = await c.get('deps').storages.get(image.storageId) if (!storage) return c.json({ error: 'Storage not found' }, 404) - const trafficAllowed = await consumeTrafficIfQuotaAllows(db, image.orgId, image.size) + const trafficAllowed = await c.get('deps').quota.consumeTrafficIfQuotaAllows(image.orgId, image.size) if (!trafficAllowed) return c.json({ error: 'Traffic quota exceeded' }, 422) let url: string try { - url = await s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS) + url = await c.get('deps').s3.presignInline(storage, image.storageKey, image.mime, PRESIGN_TTL_SECS) } catch (e) { - await refundTraffic(db, image.orgId, image.size) + await c.get('deps').quota.refundTraffic(image.orgId, image.size) throw e } @@ -85,7 +74,7 @@ async function handleImageByPath(c: Context, orgId: string, virtualPath: st if (trafficReportError) return trafficReportError try { - await incrementAccessCount(db, image.id) + await c.get('deps').imageHosting.incrementAccessCount(image.id) } catch (error) { console.error('[image-hosting-domain] incrementAccessCount failed:', error) } @@ -107,8 +96,7 @@ export async function imageHostingDomain(c: Context, next: Next): Promise): Promise { - const origin = await getSitePublicOrigin(c.get('platform').db) + const origin = await getSitePublicOrigin(c.get('deps')) return origin ? new URL(origin).host : null } export function requireFeature(name: ProFeature) { return createMiddleware(async (c, next) => { - const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT const currentHost = (await configuredPublicHost(c)) ?? normalizeHost(c.req.header('host')) ?? new URL(c.req.url).host - const state = await loadBindingState(db, { currentHost, cloudBaseUrl }) + const state = await loadBindingState(c.get('deps'), { currentHost, cloudBaseUrl }) if (!hasFeature(name, state)) { return c.json({ error: 'feature_not_available', feature: name, upgrade_url: '/settings/billing' }, 402) } diff --git a/server/openapi/downloader.ts b/server/openapi/downloader.ts index 5c8d0b4d..50c8f29b 100644 --- a/server/openapi/downloader.ts +++ b/server/openapi/downloader.ts @@ -9,8 +9,8 @@ import { presignObjectUploadPartsResponseSchema, presignObjectUploadPartsSchema, } from '@shared/schemas' -import downloadTasks from '../routes/download-tasks' -import downloaders, { downloaderSelfRoute } from '../routes/downloaders' +import downloadTasks from '../http/download-tasks' +import downloaders, { downloaderSelfRoute } from '../http/downloaders' const errorSchema = z.object({ error: z.string() }).openapi('ErrorResponse') diff --git a/server/platform/cloudflare.ts b/server/platform/cloudflare.ts index 68568f52..afda5da9 100644 --- a/server/platform/cloudflare.ts +++ b/server/platform/cloudflare.ts @@ -1,7 +1,7 @@ import { drizzle } from 'drizzle-orm/d1' import * as authSchema from '../db/auth-schema' import * as schema from '../db/schema' -import { registerEnvPublicKeys } from '../licensing/public-keys' +import { registerEnvPublicKeys } from '../domain/license-keys' import type { Platform } from './interface' interface CloudflareEnv { diff --git a/server/platform/libsql.ts b/server/platform/libsql.ts index 402d3d47..975835a6 100644 --- a/server/platform/libsql.ts +++ b/server/platform/libsql.ts @@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/libsql' import { migrate } from 'drizzle-orm/libsql/migrator' import * as authSchema from '../db/auth-schema' import * as schema from '../db/schema' -import { registerEnvPublicKeys } from '../licensing/public-keys' +import { registerEnvPublicKeys } from '../domain/license-keys' import type { Platform } from './interface' interface LibsqlEnv { diff --git a/server/platform/node.ts b/server/platform/node.ts index b1291597..b36eac72 100644 --- a/server/platform/node.ts +++ b/server/platform/node.ts @@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' import * as authSchema from '../db/auth-schema' import * as schema from '../db/schema' -import { registerEnvPublicKeys } from '../licensing/public-keys' +import { registerEnvPublicKeys } from '../domain/license-keys' import type { Platform } from './interface' export function createNodePlatform(): Platform { diff --git a/server/scheduled-worker.test.ts b/server/scheduled-worker.test.ts index af835c07..b28ed087 100644 --- a/server/scheduled-worker.test.ts +++ b/server/scheduled-worker.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { syncPendingCloudTrafficReports } from '../server/services/cloud-traffic-metering' -import { resetExpiredTrafficQuotas } from '../server/services/effective-quota' -import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/services/instance-telemetry' -import { runLicensingRefresh } from '../server/services/licensing-refresh-runner' -import { syncPendingRemoteDownloadUsageReports } from '../server/services/remote-download-usage' +import { syncPendingCloudTrafficReports } from '../server/usecases/cloud-traffic-metering' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/usecases/instance-telemetry' +import { runLicensingRefresh } from '../server/usecases/licensing-refresh-runner' +import { syncPendingRemoteDownloadUsageReports } from '../server/usecases/remote-download-usage' import { handleScheduled } from '../workers/scheduled' vi.mock('../server/platform/cloudflare', () => ({ @@ -12,24 +11,37 @@ vi.mock('../server/platform/cloudflare', () => ({ }), })) -vi.mock('../server/services/cloud-traffic-metering', () => ({ +const fakeDeps = { instance: 'instance', systemOptions: 'system-options' } +vi.mock('../server/composition', () => ({ + createDeps: vi.fn(() => fakeDeps), +})) + +vi.mock('../server/usecases/cloud-traffic-metering', () => ({ syncPendingCloudTrafficReports: vi.fn(), })) -vi.mock('../server/services/effective-quota', () => ({ - resetExpiredTrafficQuotas: vi.fn(), +const { mockCreateQuotaRepo, mockResetExpiredTrafficQuotas } = vi.hoisted(() => { + const resetExpiredTrafficQuotas = vi.fn() + return { + mockResetExpiredTrafficQuotas: resetExpiredTrafficQuotas, + mockCreateQuotaRepo: vi.fn(() => ({ resetExpiredTrafficQuotas })), + } +}) + +vi.mock('../server/adapters/repos/quota', () => ({ + createQuotaRepo: mockCreateQuotaRepo, })) -vi.mock('../server/services/instance-telemetry', () => ({ +vi.mock('../server/usecases/instance-telemetry', () => ({ INSTANCE_TELEMETRY_CRON: '0 */12 * * *', reportInstanceTelemetry: vi.fn(), })) -vi.mock('../server/services/licensing-refresh-runner', () => ({ +vi.mock('../server/usecases/licensing-refresh-runner', () => ({ runLicensingRefresh: vi.fn(), })) -vi.mock('../server/services/remote-download-usage', () => ({ +vi.mock('../server/usecases/remote-download-usage', () => ({ syncPendingRemoteDownloadUsageReports: vi.fn(), })) @@ -39,15 +51,14 @@ describe('handleScheduled', () => { vi.mocked(syncPendingRemoteDownloadUsageReports).mockReset() vi.mocked(reportInstanceTelemetry).mockReset() vi.mocked(runLicensingRefresh).mockReset() - vi.mocked(resetExpiredTrafficQuotas).mockReset() + mockResetExpiredTrafficQuotas.mockReset() }) it('syncs usage reports on the traffic cron only', async () => { await handleScheduled({ cron: '*/10 * * * *' }, { DB: {} as D1Database, ZPAN_CLOUD_URL: 'https://cloud.example' }) - expect(syncPendingCloudTrafficReports).toHaveBeenCalledWith({ db: 'db', cloudBaseUrl: 'https://cloud.example' }) - expect(syncPendingRemoteDownloadUsageReports).toHaveBeenCalledWith({ - db: 'db', + expect(syncPendingCloudTrafficReports).toHaveBeenCalledWith(fakeDeps, { cloudBaseUrl: 'https://cloud.example' }) + expect(syncPendingRemoteDownloadUsageReports).toHaveBeenCalledWith(fakeDeps, { cloudBaseUrl: 'https://cloud.example', }) expect(runLicensingRefresh).not.toHaveBeenCalled() @@ -57,7 +68,7 @@ describe('handleScheduled', () => { it('refreshes licensing on the licensing cron only', async () => { await handleScheduled({ cron: '0 */6 * * *' }, { DB: {} as D1Database, ZPAN_CLOUD_URL: 'https://cloud.example' }) - expect(runLicensingRefresh).toHaveBeenCalledWith('db', 'https://cloud.example') + expect(runLicensingRefresh).toHaveBeenCalledWith(fakeDeps, 'https://cloud.example') expect(syncPendingCloudTrafficReports).not.toHaveBeenCalled() expect(syncPendingRemoteDownloadUsageReports).not.toHaveBeenCalled() expect(reportInstanceTelemetry).not.toHaveBeenCalled() @@ -66,7 +77,8 @@ describe('handleScheduled', () => { it('resets expired traffic quotas on the monthly reset cron only', async () => { await handleScheduled({ cron: '0 0 1 * *' }, { DB: {} as D1Database, ZPAN_CLOUD_URL: 'https://cloud.example' }) - expect(resetExpiredTrafficQuotas).toHaveBeenCalledWith('db') + expect(mockResetExpiredTrafficQuotas).toHaveBeenCalled() + expect(mockCreateQuotaRepo).toHaveBeenCalledWith('db') expect(runLicensingRefresh).not.toHaveBeenCalled() expect(syncPendingCloudTrafficReports).not.toHaveBeenCalled() expect(syncPendingRemoteDownloadUsageReports).not.toHaveBeenCalled() @@ -83,8 +95,7 @@ describe('handleScheduled', () => { ) expect(reportInstanceTelemetry).toHaveBeenCalledTimes(1) - expect(reportInstanceTelemetry).toHaveBeenCalledWith({ - db: 'db', + expect(reportInstanceTelemetry).toHaveBeenCalledWith(fakeDeps, { config: { allowIp: true, }, diff --git a/server/services/activity.ts b/server/services/activity.ts deleted file mode 100644 index e4a67504..00000000 --- a/server/services/activity.ts +++ /dev/null @@ -1,157 +0,0 @@ -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' - -export type ActivityEventRow = typeof activityEvents.$inferSelect - -export interface ActivityEventWithUser extends ActivityEventRow { - user: { id: string; name: string; image: string | null } -} - -interface RecordActivityInput { - orgId: string - userId: string - action: string - targetType: string - targetId?: string - targetName: string - metadata?: Record -} - -export async function recordActivity(db: Database, event: RecordActivityInput): Promise { - 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(), - }) -} - -export async function listActivities( - db: Database, - orgId: string, - opts: { page?: number; pageSize?: number }, -): Promise<{ items: ActivityEventWithUser[]; total: number }> { - 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 } -} - -export interface AdminAuditEventWithOrg extends ActivityEventWithUser { - orgName: string | null -} - -interface ListAdminAuditOpts { - page?: number - pageSize?: number - orgId?: string - userId?: string - action?: string - targetType?: string -} - -export async function listAdminAuditEvents( - db: Database, - opts: ListAdminAuditOpts, -): Promise<{ items: AdminAuditEventWithOrg[]; total: number; page: number; pageSize: number }> { - 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 - - 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 } -} diff --git a/server/services/announcement.ts b/server/services/announcement.ts deleted file mode 100644 index 9b2ef763..00000000 --- a/server/services/announcement.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { AnnouncementInput, AnnouncementStatus } 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' - -export type Announcement = typeof announcements.$inferSelect - -export type ListAnnouncementsResult = { - items: Announcement[] - total: number - page: number - pageSize: number -} - -function pageParams(page: number, pageSize: number) { - return { - limit: pageSize, - offset: (page - 1) * pageSize, - } -} - -function publishedAtFor(input: AnnouncementInput, existing?: Announcement): Date | null { - if (input.status === 'archived') return existing?.publishedAt ?? null - if (input.status !== 'published') return null - return existing?.publishedAt ?? new Date() -} - -export async function createAnnouncement( - db: Database, - input: AnnouncementInput, - createdBy: string, -): Promise { - const now = new Date() - const row: Announcement = { - 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 row -} - -export async function listAdminAnnouncements( - db: Database, - opts: { status?: AnnouncementStatus; page: number; pageSize: number }, -): Promise { - 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, total: totalRows[0]?.count ?? 0, page: opts.page, pageSize: opts.pageSize } -} - -export async function getAnnouncement(db: Database, id: string): Promise { - const rows = await db.select().from(announcements).where(eq(announcements.id, id)).limit(1) - return rows[0] ?? null -} - -export async function updateAnnouncement( - db: Database, - id: string, - input: AnnouncementInput, -): Promise { - const existing = await getAnnouncement(db, 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)) - - return getAnnouncement(db, id) -} - -export async function deleteAnnouncement(db: Database, id: string): Promise { - const existing = await getAnnouncement(db, id) - if (!existing) return false - - await db.delete(announcements).where(eq(announcements.id, id)) - return true -} - -export async function listUserAnnouncements( - db: Database, - opts: { activeOnly: boolean; page: number; pageSize: number }, -): Promise { - 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, total: totalRows[0]?.count ?? 0, page: opts.page, pageSize: opts.pageSize } -} diff --git a/server/services/api-keys.ts b/server/services/api-keys.ts deleted file mode 100644 index 7d5314a5..00000000 --- a/server/services/api-keys.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { defaultKeyHasher } from '@better-auth/api-key' -import { eq } from 'drizzle-orm' -import { API_KEY_TEMPLATES, type ApiKeyPermissions, ApiKeyTemplate } from '../../shared/api-key-templates' -import type { Auth } from '../auth' -import { apikey } from '../db/auth-schema' -import type { Database } from '../platform/interface' - -export type VerifiedApiKey = { - id: string - configId: string - referenceId: string - permissions: ApiKeyPermissions | null -} - -type VerifyApiKeyResult = { - valid: boolean - error: { message: string; code: string; details?: { tryAgainIn?: number } } | null - key: VerifiedApiKey | null -} - -export class ApiKeyRateLimitError extends Error { - constructor( - message: string, - public readonly retryAfterMs?: number, - ) { - super(message) - this.name = 'ApiKeyRateLimitError' - } -} - -export async function verifyApiKeyForPermission( - auth: Auth, - db: Database, - key: string, - resource: string, - action: string, - configId?: string, -): Promise { - 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 -} - -export async function verifyApiKey( - auth: Auth, - db: Database, - key: string, - configId?: string, -): Promise { - 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 -} - -export function hasApiKeyPermission( - permissions: ApiKeyPermissions | null | undefined, - resource: string, - action: string, -) { - return permissions?.[resource]?.includes(action) ?? false -} - -export function isOrgApiKey(configId: string) { - return configId !== ApiKeyTemplate.WEBDAV -} - -async function verify(auth: Auth, body: Record): Promise { - 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 { - 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 -} diff --git a/server/services/archive-jobs.ts b/server/services/archive-jobs.ts deleted file mode 100644 index 6b0253df..00000000 --- a/server/services/archive-jobs.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { CreateBackgroundJobRequest } from '@shared/schemas' -import type { Platform } from '../platform/interface' -import { processArchiveJob } from './archive-processing' - -export const ARCHIVE_QUEUE_BINDING = 'ARCHIVE_QUEUE' - -export interface ArchiveJobMessage { - jobId: string - orgId: string - userId: string - request: CreateBackgroundJobRequest -} - -interface QueueProducer { - send(message: ArchiveJobMessage): Promise -} - -class LocalArchiveQueue { - private readonly pending: Array<{ platform: Platform; message: ArchiveJobMessage }> = [] - private running = false - - push(platform: Platform, message: ArchiveJobMessage): void { - this.pending.push({ platform, message }) - if (!this.running) setTimeout(() => void this.drain(), 0) - } - - private async drain(): Promise { - if (this.running) return - this.running = true - - try { - for (;;) { - const next = this.pending.shift() - if (!next) return - try { - await runArchiveJobMessage(next.platform, next.message) - } catch (error) { - console.error('[archive-jobs] local worker failed:', error) - } - } - } finally { - this.running = false - if (this.pending.length > 0) setTimeout(() => void this.drain(), 0) - } - } -} - -const localArchiveQueue = new LocalArchiveQueue() - -export async function dispatchArchiveJob(platform: Platform, message: ArchiveJobMessage): Promise { - const queue = platform.getBinding(ARCHIVE_QUEUE_BINDING) - if (queue) { - await queue.send(message) - return - } - - localArchiveQueue.push(platform, message) -} - -export async function runArchiveJobMessage(platform: Platform, message: ArchiveJobMessage): Promise { - await processArchiveJob(platform.db, { - orgId: message.orgId, - userId: message.userId, - request: message.request, - jobId: message.jobId, - }) -} diff --git a/server/services/background-jobs.ts b/server/services/background-jobs.ts deleted file mode 100644 index 91bb31cc..00000000 --- a/server/services/background-jobs.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { BackgroundJob, BackgroundJobProgress, BackgroundJobStatus, BackgroundJobType } 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' - -export type BackgroundJobMetadata = Record - -export type CreateBackgroundJobInput = { - orgId: string - userId: string - type: BackgroundJobType - targetFolder?: string | null - targetPath?: string | null - metadata?: BackgroundJobMetadata | null - progress?: Partial - retryable?: boolean - cancelable?: boolean -} - -export type ListBackgroundJobsOptions = { - status?: BackgroundJobStatus - type?: string - page: number - pageSize: number -} - -export type UpdateBackgroundJobInput = { - status?: BackgroundJobStatus - progress?: Partial - errorMessage?: string | null - resultMetadata?: BackgroundJobMetadata | null - retryable?: boolean - cancelable?: boolean - startedAt?: Date | null - finishedAt?: Date | null -} - -export class BackgroundJobError extends Error { - constructor( - readonly code: 'not_found' | 'not_cancelable' | 'not_retryable', - message = code, - ) { - super(message) - this.name = 'BackgroundJobError' - } -} - -type BackgroundJobRow = typeof backgroundJobs.$inferSelect - -const ACTIVE_STATUSES: BackgroundJobStatus[] = ['queued', 'running'] - -export async function createBackgroundJob(db: Database, input: CreateBackgroundJobInput): Promise { - 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) -} - -export async function listBackgroundJobs( - db: Database, - orgId: string, - opts: ListBackgroundJobsOptions, -): Promise<{ items: BackgroundJob[]; total: number }> { - 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, - } -} - -export async function getBackgroundJob(db: Database, orgId: string, id: string): Promise { - const row = await getBackgroundJobRow(db, orgId, id) - if (!row) throw new BackgroundJobError('not_found') - return toBackgroundJob(row) -} - -export async function updateBackgroundJob( - db: Database, - orgId: string, - id: string, - input: UpdateBackgroundJobInput, -): Promise { - const row = await getBackgroundJobRow(db, orgId, id) - if (!row) throw new BackgroundJobError('not_found') - - const nextStatus = input.status ?? row.status - const now = new Date() - const values: Partial = { - 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 getBackgroundJob(db, orgId, id) -} - -export async function cancelBackgroundJob(db: Database, orgId: string, id: string): Promise { - const row = await getBackgroundJobRow(db, 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 getBackgroundJob(db, orgId, id) -} - -export async function retryBackgroundJob(db: Database, orgId: string, id: string): Promise { - const row = await getBackgroundJobRow(db, 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) -} - -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) -} - -async function getBackgroundJobRow(db: Database, orgId: string, id: string): Promise { - const rows = await db - .select() - .from(backgroundJobs) - .where(and(eq(backgroundJobs.id, id), eq(backgroundJobs.orgId, orgId))) - .limit(1) - return rows[0] ?? null -} - -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), - } -} diff --git a/server/services/captcha.ts b/server/services/captcha.ts deleted file mode 100644 index e0feb45e..00000000 --- a/server/services/captcha.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { CaptchaOptions } from 'better-auth/plugins' -import { eq, inArray } from 'drizzle-orm' -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' -import { systemOptions } from '../db/schema' -import type { Database } from '../platform/interface' - -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 -} - -type CaptchaOptionValues = Partial> - -const CAPTCHA_OPTION_KEYS = [ - CAPTCHA_ENABLED_KEY, - CAPTCHA_PROVIDER_KEY, - CAPTCHA_SITE_KEY_KEY, - CAPTCHA_SECRET_OPTION_KEY, - CAPTCHA_MIN_SCORE_KEY, -] as const - -export async function isCaptchaEnabled(db: Database): Promise { - const [row] = await db - .select({ value: systemOptions.value }) - .from(systemOptions) - .where(eq(systemOptions.key, CAPTCHA_ENABLED_KEY)) - return row?.value === 'true' -} - -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 } -} - -export async function loadCaptchaConfig(db: Database): Promise { - const rows = await db - .select({ key: systemOptions.key, value: systemOptions.value }) - .from(systemOptions) - .where(inArray(systemOptions.key, [...CAPTCHA_OPTION_KEYS])) - - const values: CaptchaOptionValues = {} - for (const row of rows) values[row.key] = row.value - return readCaptchaConfig(values) -} - -export async function loadCaptchaOptionValues(db: Database): Promise { - const rows = await db - .select({ key: systemOptions.key, value: systemOptions.value }) - .from(systemOptions) - .where(inArray(systemOptions.key, [...CAPTCHA_OPTION_KEYS])) - - const values: CaptchaOptionValues = {} - for (const row of rows) values[row.key] = row.value - return values -} - -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 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 -} diff --git a/server/services/download-tokens.ts b/server/services/download-tokens.ts deleted file mode 100644 index 11e2bf30..00000000 --- a/server/services/download-tokens.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { eq } from 'drizzle-orm' -import { z } from 'zod' -import { downloaders, downloadTasks } from '../db/schema' -import { constantTimeEqual } from '../lib/constant-time' -import type { Database, Platform } from '../platform/interface' - -const TOKEN_VERSION = 1 - -const downloaderTokenSchema = z.object({ - v: z.literal(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(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 type DownloaderTokenClaims = z.infer -export type TaskUploadTokenClaims = z.infer -export type DownloadTokenClaims = DownloaderTokenClaims | TaskUploadTokenClaims - -export async function signDownloadToken(platform: Platform, claims: DownloadTokenClaims): Promise { - const payload = base64UrlEncode(JSON.stringify(claims)) - const signature = await signPayload(platform, payload) - return `${payload}.${signature}` -} - -export async function verifyDownloadToken(platform: Platform, token: string): Promise { - 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 -} - -export async function hashDownloadToken(platform: Platform, token: string): Promise { - const key = await hmacKey(secret(platform)) - const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`hash:${token}`)) - return base64UrlEncodeBytes(new Uint8Array(signature)) -} - -export async function resolveDownloaderToken( - platform: Platform, - token: string, -): Promise<{ downloaderId: string } | null> { - 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 } -} - -export async function resolveTaskUploadToken( - db: Database, - platform: Platform, - token: string, -): Promise { - 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 { - 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 { - 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) -} diff --git a/server/services/downloads.ts b/server/services/downloads.ts deleted file mode 100644 index 7cea96b4..00000000 --- a/server/services/downloads.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './downloads/core' -export * from './downloads/types' diff --git a/server/services/downloads/core.ts b/server/services/downloads/core.ts deleted file mode 100644 index 8ff45031..00000000 --- a/server/services/downloads/core.ts +++ /dev/null @@ -1,787 +0,0 @@ -import type { - CreateDownloaderInput, - CreateDownloadTaskInput, - DownloaderHeartbeatInput, - DownloadTaskActionInput, - UpdateDownloaderInput, - UpdateDownloadTaskInput, -} from '@shared/schemas' -import { downloadTaskRuntimeSchema } from '@shared/schemas' -import type { Downloader, DownloadTask, DownloadTaskRuntime } from '@shared/types' -import { and, asc, count, desc, eq, gt, gte, inArray, like, lt, sql } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { downloaders, downloadTasks } from '../../db/schema' -import type { Platform } from '../../platform/interface' -import { hashDownloadToken, signDownloadToken } from '../download-tokens' -import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from '../remote-download-usage' -import { parseCapabilities, toDownloader, toDownloadTask } from './mappers' -import { DownloadError, type DownloaderRow, type DownloadTaskRow } from './types' - -const DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES = 100 * 1024 * 1024 -const UPLOAD_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60 -const DOWNLOADER_HEARTBEAT_LEASE_MS = 30_000 -const PAUSABLE_TASK_STATUSES = ['queued', 'assigned', 'downloading'] as const -const CANCELABLE_TASK_STATUSES = [ - 'queued', - 'assigned', - 'downloading', - 'suspended', - 'paused', - 'interrupted', - 'uploading', - 'pausing', -] as const -const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'canceled'] as const -const EXECUTABLE_TASK_STATUSES = ['queued', 'assigned', 'downloading', 'uploading'] as const -const RESTARTABLE_TASK_STATUSES = [ - 'queued', - 'assigned', - 'paused', - 'interrupted', - 'suspended', - 'failed', - 'canceled', - 'completed', -] as const -const DOWNLOADER_TOKEN_TASK_STATUSES = ['assigned', 'downloading', 'uploading', 'interrupted'] as const - -export async function createDownloader( - platform: Platform, - input: CreateDownloaderInput, - userId: string, -): Promise<{ downloader: Downloader; token: string }> { - const now = new Date() - const id = nanoid() - const jti = nanoid() - const token = await signDownloadToken(platform, { - v: 1, - typ: 'downloader', - downloaderId: id, - jti, - iat: Math.floor(now.getTime() / 1000), - }) - await platform.db.insert(downloaders).values({ - id, - name: input.name, - tokenHash: await hashDownloadToken(platform, token), - tokenJti: jti, - status: 'offline', - enabled: true, - version: input.heartbeat.version, - hostname: input.heartbeat.hostname, - platform: input.heartbeat.platform, - arch: input.heartbeat.arch, - engine: input.heartbeat.engine, - capabilities: JSON.stringify(input.heartbeat.capabilities), - maxConcurrentTasks: input.heartbeat.maxConcurrentTasks, - currentTasks: input.heartbeat.currentTasks, - downloadBps: input.heartbeat.downloadBps, - uploadBps: input.heartbeat.uploadBps, - freeDiskBytes: input.heartbeat.freeDiskBytes, - remoteDownloadCreditBillingEnabled: false, - remoteDownloadCreditUnitBytes: DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES, - remoteDownloadCreditPerUnit: 1, - lastHeartbeatAt: null, - createdBy: userId, - createdAt: now, - updatedAt: now, - }) - return { downloader: await getDownloader(platform, id), token } -} - -export async function listDownloaders(platform: Platform): Promise { - await recoverStaleDownloaderAssignments(platform) - const rows = await platform.db.select().from(downloaders).orderBy(desc(downloaders.createdAt)) - return rows.map(toDownloader) -} - -export async function getDownloader(platform: Platform, id: string): Promise { - const rows = await platform.db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1) - if (!rows[0]) throw new DownloadError('not_found') - return toDownloader(rows[0]) -} - -export async function updateDownloader( - platform: Platform, - id: string, - input: UpdateDownloaderInput, -): Promise { - const rows = await platform.db.select({ id: downloaders.id }).from(downloaders).where(eq(downloaders.id, id)).limit(1) - if (!rows[0]) throw new DownloadError('not_found') - const now = new Date() - await platform.db - .update(downloaders) - .set({ - ...(input.name !== undefined ? { name: input.name } : {}), - ...(input.enabled !== undefined - ? { enabled: input.enabled, status: input.enabled ? 'offline' : 'disabled' } - : {}), - ...(input.remoteDownloadCreditBillingEnabled !== undefined - ? { remoteDownloadCreditBillingEnabled: input.remoteDownloadCreditBillingEnabled } - : {}), - ...(input.remoteDownloadCreditUnitBytes !== undefined - ? { remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes } - : {}), - ...(input.remoteDownloadCreditPerUnit !== undefined - ? { remoteDownloadCreditPerUnit: input.remoteDownloadCreditPerUnit } - : {}), - updatedAt: now, - }) - .where(eq(downloaders.id, id)) - return getDownloader(platform, id) -} - -export async function deleteDownloader(platform: Platform, id: string): Promise<{ id: string; deleted: true }> { - const rows = await platform.db.select({ id: downloaders.id }).from(downloaders).where(eq(downloaders.id, id)).limit(1) - if (!rows[0]) throw new DownloadError('not_found') - const now = new Date() - - await platform.db - .update(downloadTasks) - .set({ - status: 'queued', - assignedDownloaderId: null, - runtime: null, - assignedAt: null, - updatedAt: now, - }) - .where( - and( - eq(downloadTasks.assignedDownloaderId, id), - inArray(downloadTasks.status, [ - 'queued', - 'assigned', - 'downloading', - 'suspended', - 'pausing', - 'paused', - 'interrupted', - 'uploading', - 'canceling', - ]), - ), - ) - await platform.db.delete(downloaders).where(eq(downloaders.id, id)) - return { id, deleted: true } -} - -export async function recordDownloaderHeartbeat( - platform: Platform, - downloaderId: string, - heartbeat: DownloaderHeartbeatInput, -): Promise { - const rows = await platform.db - .select({ id: downloaders.id, enabled: downloaders.enabled }) - .from(downloaders) - .where(eq(downloaders.id, downloaderId)) - .limit(1) - if (!rows[0]) throw new DownloadError('not_found') - const now = new Date() - await platform.db - .update(downloaders) - .set({ - status: rows[0].enabled ? 'online' : 'disabled', - version: heartbeat.version, - hostname: heartbeat.hostname, - platform: heartbeat.platform, - arch: heartbeat.arch, - engine: heartbeat.engine, - capabilities: JSON.stringify(heartbeat.capabilities), - maxConcurrentTasks: heartbeat.maxConcurrentTasks, - currentTasks: heartbeat.currentTasks, - downloadBps: heartbeat.downloadBps, - uploadBps: heartbeat.uploadBps, - freeDiskBytes: heartbeat.freeDiskBytes, - lastHeartbeatAt: now, - updatedAt: now, - }) - .where(eq(downloaders.id, downloaderId)) - await assignQueuedTasks(platform) - return getDownloader(platform, downloaderId) -} - -export async function createDownloadTask( - platform: Platform, - orgId: string, - userId: string, - input: CreateDownloadTaskInput, -): Promise { - const now = new Date() - const id = nanoid() - await recoverStaleDownloaderAssignments(platform) - const assigned = await selectDownloader(platform, input.source.type) - await platform.db.insert(downloadTasks).values({ - id, - orgId, - createdByUserId: userId, - sourceType: input.source.type, - sourceUri: input.source.uri, - displayName: input.name ?? null, - targetFolder: input.targetFolder, - category: input.category ?? null, - tags: JSON.stringify(input.tags ?? []), - assignedDownloaderId: assigned?.id ?? null, - status: assigned ? 'assigned' : 'queued', - createdAt: now, - updatedAt: now, - assignedAt: assigned ? now : null, - }) - return getDownloadTask(platform, orgId, id) -} - -export async function listDownloadTasks( - platform: Platform, - opts: { - orgId?: string - downloaderId?: string - status?: string - category?: string - tag?: string - sortBy?: 'createdAt' | 'source' | 'category' | 'tags' | 'status' | 'progress' | 'eta' - sortDir?: 'asc' | 'desc' - page: number - pageSize: number - includeUploadToken?: boolean - }, -): Promise<{ items: DownloadTask[]; total: number }> { - const offset = (opts.page - 1) * opts.pageSize - const filters = [] - if (opts.orgId) filters.push(eq(downloadTasks.orgId, opts.orgId)) - if (opts.downloaderId) filters.push(eq(downloadTasks.assignedDownloaderId, opts.downloaderId)) - if (opts.status) filters.push(eq(downloadTasks.status, opts.status)) - if (opts.category) filters.push(eq(downloadTasks.category, opts.category)) - if (opts.tag) filters.push(like(downloadTasks.tags, `%${JSON.stringify(opts.tag)}%`)) - const where = filters.length ? and(...filters) : undefined - const [rows, totalRows] = await Promise.all([ - platform.db - .select() - .from(downloadTasks) - .where(where) - .orderBy(downloadTaskOrderBy(opts.sortBy ?? 'createdAt', opts.sortDir ?? 'desc')) - .limit(opts.pageSize) - .offset(offset), - platform.db.select({ count: count() }).from(downloadTasks).where(where), - ]) - return { - items: await Promise.all( - rows.map((row) => - toDownloadTaskWithToken( - platform, - row, - (opts.includeUploadToken ?? false) && - DOWNLOADER_TOKEN_TASK_STATUSES.includes(row.status as (typeof DOWNLOADER_TOKEN_TASK_STATUSES)[number]), - ), - ), - ), - total: totalRows[0]?.count ?? 0, - } -} - -function downloadTaskOrderBy( - sortBy: 'createdAt' | 'source' | 'category' | 'tags' | 'status' | 'progress' | 'eta', - sortDir: 'asc' | 'desc', -) { - 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` - 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`coalesce(json_extract(${downloadTasks.runtime}, '$.etaSeconds'), 9223372036854775807)`) - } - return direction(downloadTasks.createdAt) -} - -export async function getDownloadTask(platform: Platform, orgId: string, id: string): Promise { - const rows = await platform.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]) -} - -export async function updateDownloadTask( - platform: Platform, - id: string, - input: UpdateDownloadTaskInput, - actor: { orgId?: string; downloaderId?: string }, -): Promise { - const rows = await platform.db.select().from(downloadTasks).where(eq(downloadTasks.id, id)).limit(1) - const task = rows[0] - if (!task) throw new DownloadError('not_found') - if (actor.orgId && task.orgId !== actor.orgId) throw new DownloadError('not_found') - if (actor.downloaderId && task.assignedDownloaderId !== actor.downloaderId) throw new DownloadError('forbidden') - if (actor.downloaderId && task.status === 'pausing' && input.status === 'paused') { - const now = new Date() - await platform.db - .update(downloadTasks) - .set({ status: 'paused', runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), updatedAt: now }) - .where(eq(downloadTasks.id, id)) - return getDownloadTask(platform, task.orgId, id) - } - if (actor.downloaderId && task.status === 'canceling' && input.status === 'canceled') { - const now = new Date() - await platform.db - .update(downloadTasks) - .set({ - status: 'canceled', - runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), - finishedAt: task.finishedAt ?? now, - updatedAt: now, - }) - .where(eq(downloadTasks.id, id)) - return getDownloadTask(platform, task.orgId, id) - } - if (actor.downloaderId && ['pausing', 'paused', 'canceling', 'canceled'].includes(task.status)) { - throw new DownloadError('invalid_state', `Task is ${task.status}`) - } - if (actor.orgId && !actor.downloaderId) { - const onlyCancel = - input.status === 'canceled' && - input.progress === undefined && - input.errorMessage === undefined && - input.resultObjectId === undefined && - input.runtime === undefined - if (!onlyCancel) throw new DownloadError('forbidden') - } - if (actor.downloaderId && isRetainedSeedReport(input) && task.status !== 'completed') { - return toDownloadTask(task) - } - - const now = new Date() - let status = input.status ?? task.status - let billingAuthorizedBytes = task.billingAuthorizedBytes - let billingChargedBytes = task.billingChargedBytes - let billingChargedCredits = task.billingChargedCredits - let billingStatus = task.billingStatus - const currentRuntime = parseTaskRuntime(task.runtime) - const nextRuntime = nextTaskRuntime(currentRuntime, input.runtime, input.progress, status, now) - const currentDownloadedBytes = currentRuntime?.progress?.download.bytes ?? 0 - const nextDownloadedBytes = nextRuntime?.progress?.download.bytes ?? currentDownloadedBytes - - if (actor.downloaderId && nextDownloadedBytes > currentDownloadedBytes) { - const downloader = await loadDownloaderRow(platform, actor.downloaderId) - const targetUnits = Math.ceil(nextDownloadedBytes / downloader.remoteDownloadCreditUnitBytes) - const currentUnits = Math.ceil(task.billingChargedBytes / downloader.remoteDownloadCreditUnitBytes) - try { - for (let unit = currentUnits + 1; unit <= targetUnits; unit += 1) { - await reportRemoteDownloadUnit({ - platform, - orgId: task.orgId, - downloaderId: actor.downloaderId, - taskId: task.id, - unitIndex: unit, - unitBytes: downloader.remoteDownloadCreditUnitBytes, - creditsPerUnit: downloader.remoteDownloadCreditPerUnit, - enabled: downloader.remoteDownloadCreditBillingEnabled, - }) - billingChargedCredits += downloader.remoteDownloadCreditBillingEnabled - ? downloader.remoteDownloadCreditPerUnit - : 0 - } - if (targetUnits > currentUnits) { - billingChargedBytes = targetUnits * downloader.remoteDownloadCreditUnitBytes - billingAuthorizedBytes = billingChargedBytes - billingStatus = 'ok' - } - } catch (error) { - if (error instanceof RemoteDownloadBillingBlockedError) { - status = 'suspended' - billingStatus = 'insufficient_credits' - } else { - throw error - } - } - } - - const nextFinishedAt = - task.finishedAt ?? (input.status !== undefined && ['completed', 'failed', 'canceled'].includes(status) ? now : null) - - await platform.db - .update(downloadTasks) - .set({ - status, - billingAuthorizedBytes, - billingChargedBytes, - billingChargedCredits, - billingStatus, - errorMessage: input.errorMessage === undefined ? task.errorMessage : input.errorMessage, - resultObjectId: input.resultObjectId === undefined ? task.resultObjectId : input.resultObjectId, - runtime: serializeTaskRuntime(nextRuntime), - startedAt: task.startedAt ?? (status === 'downloading' ? now : null), - finishedAt: nextFinishedAt, - updatedAt: now, - }) - .where(eq(downloadTasks.id, id)) - - return getDownloadTask(platform, task.orgId, id) -} - -export async function performDownloadTaskAction( - platform: Platform, - orgId: string, - id: string, - action: DownloadTaskActionInput['action'], -): Promise { - const rows = await platform.db - .select() - .from(downloadTasks) - .where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId))) - .limit(1) - const task = rows[0] - if (!task) throw new DownloadError('not_found') - - if (action === 'delete') { - if (!TERMINAL_TASK_STATUSES.includes(task.status as (typeof TERMINAL_TASK_STATUSES)[number])) { - throw new DownloadError('invalid_state', 'Only completed, failed, or canceled tasks can be deleted') - } - await platform.db.delete(downloadTasks).where(eq(downloadTasks.id, id)) - return { id, deleted: true } - } - - const now = new Date() - if (action === 'pause') { - if (task.status === 'paused') return toDownloadTask(task) - if (!PAUSABLE_TASK_STATUSES.includes(task.status as (typeof PAUSABLE_TASK_STATUSES)[number])) { - throw new DownloadError('invalid_state', 'Only queued, assigned, or downloading tasks can be paused') - } - const status = task.status === 'downloading' ? 'pausing' : 'paused' - await platform.db - .update(downloadTasks) - .set({ status, runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), updatedAt: now }) - .where(eq(downloadTasks.id, id)) - return getDownloadTask(platform, orgId, id) - } - - if (action === 'resume') { - if (!['paused', 'suspended'].includes(task.status)) { - throw new DownloadError('invalid_state', 'Only paused or suspended tasks can be resumed') - } - await platform.db - .update(downloadTasks) - .set({ - status: 'queued', - assignedDownloaderId: null, - assignedAt: null, - runtime: clearTaskRuntimeMessageJson(task.runtime), - updatedAt: now, - }) - .where(eq(downloadTasks.id, id)) - await assignQueuedTasks(platform) - return getDownloadTask(platform, orgId, id) - } - - if (action === 'cancel') { - if (task.status === 'canceled') return toDownloadTask(task) - if (!CANCELABLE_TASK_STATUSES.includes(task.status as (typeof CANCELABLE_TASK_STATUSES)[number])) { - throw new DownloadError('invalid_state', 'Only active, interrupted, suspended, or paused tasks can be canceled') - } - const status = - task.assignedDownloaderId && - ['assigned', 'downloading', 'uploading', 'pausing', 'interrupted'].includes(task.status) - ? 'canceling' - : 'canceled' - await platform.db - .update(downloadTasks) - .set({ - status, - runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), - finishedAt: status === 'canceled' ? (task.finishedAt ?? now) : task.finishedAt, - updatedAt: now, - }) - .where(eq(downloadTasks.id, id)) - return getDownloadTask(platform, orgId, id) - } - - if (action === 'retry') { - if (task.status !== 'failed') { - throw new DownloadError('invalid_state', 'Only failed tasks can be retried') - } - await platform.db - .update(downloadTasks) - .set({ - status: 'queued', - assignedDownloaderId: null, - errorCode: null, - errorMessage: null, - resultObjectId: null, - runtime: clearTaskRuntimeMessageJson(task.runtime), - assignedAt: null, - startedAt: null, - finishedAt: null, - updatedAt: now, - }) - .where(eq(downloadTasks.id, id)) - await assignQueuedTasks(platform) - return getDownloadTask(platform, orgId, id) - } - - if (action === 'restart') { - if (!RESTARTABLE_TASK_STATUSES.includes(task.status as (typeof RESTARTABLE_TASK_STATUSES)[number])) { - throw new DownloadError('invalid_state', 'Only inactive tasks can be restarted') - } - await platform.db - .update(downloadTasks) - .set({ - status: 'queued', - assignedDownloaderId: null, - attempt: task.attempt + 1, - billingAuthorizedBytes: 0, - billingChargedBytes: 0, - billingChargedCredits: 0, - billingStatus: 'none', - errorCode: null, - errorMessage: null, - resultObjectId: null, - runtime: null, - assignedAt: null, - startedAt: null, - finishedAt: null, - updatedAt: now, - }) - .where(eq(downloadTasks.id, id)) - await assignQueuedTasks(platform) - return getDownloadTask(platform, orgId, id) - } - - throw new DownloadError('invalid_state') -} - -function isRetainedSeedReport(input: UpdateDownloadTaskInput): boolean { - return input.status === undefined && input.runtime?.phase === 'seeding' -} - -function nextTaskRuntime( - current: DownloadTaskRuntime | null, - input: UpdateDownloadTaskInput['runtime'], - progress: UpdateDownloadTaskInput['progress'], - status: string, - now: Date, -): DownloadTaskRuntime | null { - const runtime = input === undefined ? current : input - const merged = mergeTaskRuntime(runtime, progress, now) - if (!EXECUTABLE_TASK_STATUSES.includes(status as (typeof EXECUTABLE_TASK_STATUSES)[number])) { - return merged - } - return clearTaskRuntimeMessage(merged) -} - -function mergeTaskRuntime( - runtime: DownloadTaskRuntime | null | undefined, - progress: UpdateDownloadTaskInput['progress'], - now: Date, -): DownloadTaskRuntime | null { - const next = runtime ? { ...runtime } : null - if (!progress) return next - const base = next ?? {} - return { - ...base, - updatedAt: now.toISOString(), - progress: mergeTaskProgress(base.progress, progress), - } -} - -function mergeTaskProgress( - current: DownloadTaskRuntime['progress'] | undefined, - patch: UpdateDownloadTaskInput['progress'] | DownloadTaskRuntime['progress'] | undefined, -): NonNullable { - return { - download: { - bytes: Math.max(patch?.download?.bytes ?? 0, current?.download.bytes ?? 0), - totalBytes: patch?.download?.totalBytes ?? current?.download.totalBytes ?? null, - bytesPerSecond: patch?.download?.bytesPerSecond ?? current?.download.bytesPerSecond ?? 0, - }, - upload: { - bytes: Math.max(patch?.upload?.bytes ?? 0, current?.upload.bytes ?? 0), - totalBytes: patch?.upload?.totalBytes ?? current?.upload.totalBytes ?? null, - bytesPerSecond: patch?.upload?.bytesPerSecond ?? current?.upload.bytesPerSecond ?? 0, - }, - } -} - -function stoppedRuntime(value: string | null): DownloadTaskRuntime | null { - const runtime = parseTaskRuntime(value) - if (!runtime?.progress) return runtime - return { - ...runtime, - progress: { - download: { ...runtime.progress.download, bytesPerSecond: 0 }, - upload: { ...runtime.progress.upload, bytesPerSecond: 0 }, - }, - seeding: runtime.seeding ? { ...runtime.seeding, uploadBytesPerSecond: 0 } : runtime.seeding, - } -} - -function clearTaskRuntimeMessageJson(value: string | null): string | null { - return serializeTaskRuntime(clearTaskRuntimeMessage(parseTaskRuntime(value))) -} - -function clearTaskRuntimeMessage(runtime: DownloadTaskRuntime | null): DownloadTaskRuntime | null { - if (!runtime?.message) return runtime - const { message: _message, ...rest } = runtime - return Object.keys(rest).length > 0 ? rest : null -} - -function parseTaskRuntime(value: string | null): DownloadTaskRuntime | null { - if (!value) return null - return downloadTaskRuntimeSchema.parse(JSON.parse(value)) -} - -function serializeTaskRuntime(runtime: DownloadTaskRuntime | null | undefined): string | null { - return runtime && Object.keys(runtime).length > 0 ? JSON.stringify(runtime) : null -} - -export async function assertTaskUploadAllowed(platform: Platform, params: { taskId: string; downloaderId: string }) { - const rows = await platform.db.select().from(downloadTasks).where(eq(downloadTasks.id, params.taskId)).limit(1) - const task = rows[0] - if (!task || task.assignedDownloaderId !== params.downloaderId) throw new DownloadError('forbidden') - if (!['assigned', 'downloading', 'uploading'].includes(task.status)) throw new DownloadError('invalid_state') - return task -} - -async function assignQueuedTasks(platform: Platform): Promise { - await recoverStaleDownloaderAssignments(platform) - const tasks = await platform.db - .select() - .from(downloadTasks) - .where(eq(downloadTasks.status, 'queued')) - .orderBy(asc(downloadTasks.createdAt)) - .limit(20) - for (const task of tasks) { - const downloader = await selectDownloader(platform, task.sourceType) - if (!downloader) continue - const now = new Date() - await platform.db - .update(downloadTasks) - .set({ - status: 'assigned', - assignedDownloaderId: downloader.id, - assignedAt: now, - updatedAt: now, - }) - .where(eq(downloadTasks.id, task.id)) - } -} - -async function selectDownloader(platform: Platform, sourceType: string): Promise { - const needed = sourceType === 'http' ? ['http'] : ['magnet', 'torrent'] - const leaseCutoff = new Date(Date.now() - DOWNLOADER_HEARTBEAT_LEASE_MS) - const rows = await platform.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.find((row) => { - const capabilities = parseCapabilities(row.capabilities) - return needed.some((capability) => capabilities.includes(capability)) - }) ?? null - ) -} - -async function recoverStaleDownloaderAssignments(platform: Platform): Promise { - const now = new Date() - const leaseCutoff = new Date(now.getTime() - DOWNLOADER_HEARTBEAT_LEASE_MS) - const staleDownloaders = await platform.db - .select({ id: downloaders.id }) - .from(downloaders) - .where( - and( - eq(downloaders.enabled, true), - eq(downloaders.status, 'online'), - lt(downloaders.lastHeartbeatAt, leaseCutoff), - ), - ) - if (staleDownloaders.length === 0) return - - const staleIds = staleDownloaders.map((downloader) => downloader.id) - await platform.db - .update(downloadTasks) - .set({ - status: 'queued', - assignedDownloaderId: null, - assignedAt: null, - runtime: null, - updatedAt: now, - }) - .where( - and( - inArray(downloadTasks.assignedDownloaderId, staleIds), - inArray(downloadTasks.status, ['assigned', 'downloading', 'uploading', 'interrupted']), - ), - ) - await platform.db - .update(downloaders) - .set({ status: 'offline', currentTasks: 0, downloadBps: 0, uploadBps: 0, updatedAt: now }) - .where(inArray(downloaders.id, staleIds)) -} - -async function createTaskUploadToken( - platform: Platform, - params: { - taskId: string - downloaderId: string - orgId: string - targetFolder: string - createdByUserId: string - assignedAt: Date - }, -): Promise { - const issuedAt = Math.floor(params.assignedAt.getTime() / 1000) - const exp = issuedAt + UPLOAD_TOKEN_TTL_SECONDS - return signDownloadToken(platform, { - v: 1, - typ: 'download-task-upload', - taskId: params.taskId, - downloaderId: params.downloaderId, - orgId: params.orgId, - targetFolder: params.targetFolder, - createdByUserId: params.createdByUserId, - scopes: ['objects:create', 'objects:upload', 'objects:confirm'], - jti: `${params.taskId}:${params.downloaderId}:${params.assignedAt.getTime()}`, - iat: issuedAt, - exp, - }) -} - -async function toDownloadTaskWithToken(platform: Platform, row: DownloadTaskRow, includeUploadToken: boolean) { - const task = toDownloadTask(row) - if (includeUploadToken && task.status.assignment && row.assignedAt) { - task.status.assignment.uploadToken = await createTaskUploadToken(platform, { - taskId: row.id, - downloaderId: task.status.assignment.downloaderId, - orgId: row.orgId, - targetFolder: row.targetFolder, - createdByUserId: row.createdByUserId, - assignedAt: row.assignedAt, - }) - } - return task -} - -async function loadDownloaderRow(platform: Platform, id: string): Promise { - const rows = await platform.db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1) - if (!rows[0]) throw new DownloadError('not_found') - return rows[0] -} diff --git a/server/services/downloads/mappers.ts b/server/services/downloads/mappers.ts deleted file mode 100644 index df003894..00000000 --- a/server/services/downloads/mappers.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { downloadTaskRuntimeSchema } from '@shared/schemas' -import type { Downloader, DownloadTask } from '@shared/types' -import type { DownloaderRow, DownloadTaskRow } from './types' - -export 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(), - } -} - -export 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: parseTaskTags(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(), - } -} - -export function parseCapabilities(value: string): string[] { - return parseStringArray(value) -} - -function parseTaskRuntime(value: string | null): DownloadTask['status']['runtime'] { - 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 parseTaskTags(value: string): string[] { - return parseStringArray(value) -} - -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 [] - } -} diff --git a/server/services/downloads/types.ts b/server/services/downloads/types.ts deleted file mode 100644 index a3ba9476..00000000 --- a/server/services/downloads/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { downloaders, downloadTasks } from '../../db/schema' - -export class DownloadError extends Error { - constructor( - readonly code: 'not_found' | 'forbidden' | 'no_downloader' | 'invalid_state' | 'unsupported_source', - message: string = code, - ) { - super(message) - this.name = 'DownloadError' - } -} - -export type DownloaderRow = typeof downloaders.$inferSelect -export type DownloadTaskRow = typeof downloadTasks.$inferSelect diff --git a/server/services/email.ts b/server/services/email.ts deleted file mode 100644 index b11df294..00000000 --- a/server/services/email.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { like } from 'drizzle-orm' -import { systemOptions } from '../db/schema' -import type { Database, Platform } from '../platform/interface' - -export interface EmailMessage { - to: string - subject: string - html: string - text?: string -} - -export type EmailProvider = 'smtp' | 'http' | 'cloudflare' - -export interface SmtpConfig { - host: string - port: number - user: string - pass: string - secure: boolean -} - -export interface HttpConfig { - url: string - apiKey: string -} - -export interface CloudflareEmailBinding { - send(message: { - to: string | string[] - from: string | { email: string; name: string } - subject: string - html?: string - text?: string - }): Promise<{ messageId: string }> -} - -export type EmailConfig = - | { provider: 'smtp'; from: string; smtp: SmtpConfig } - | { provider: 'http'; from: string; http: HttpConfig } - | { provider: 'cloudflare'; from: string } - -export interface EmailSettings { - enabled: boolean - config: EmailConfig | null -} - -export type EmailSource = Database | Platform - -const CLOUDFLARE_EMAIL_BINDING = 'EMAIL' - -function getDb(source: EmailSource): Database { - return 'db' in source ? source.db : source -} - -function getPlatform(source: EmailSource): Platform | undefined { - return 'db' in source ? source : undefined -} - -function getCloudflareBinding(platform: Platform | undefined): CloudflareEmailBinding | undefined { - return platform?.getBinding(CLOUDFLARE_EMAIL_BINDING) -} - -async function loadEmailOptions(db: Database): Promise> { - const rows = await db - .select({ key: systemOptions.key, value: systemOptions.value }) - .from(systemOptions) - .where(like(systemOptions.key, 'email_%')) - return new Map(rows.map((r) => [r.key, r.value])) -} - -function isEmailEnabledOption(opts: Map): boolean { - return opts.get('email_enabled') === 'true' -} - -export async function getEmailConfig(source: EmailSource): Promise { - const db = getDb(source) - const platform = getPlatform(source) - const opts = await loadEmailOptions(db) - - 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 } } -} - -export async function getEmailSettings(source: EmailSource): Promise { - const db = getDb(source) - const opts = await loadEmailOptions(db) - const enabled = isEmailEnabledOption(opts) - - try { - const config = await getEmailConfig(source) - return { enabled, config } - } catch (error) { - if ( - error instanceof Error && - (error.message.includes('Email provider not configured') || error.message.includes('Email sender not configured')) - ) { - return { enabled, config: null } - } - throw error - } -} - -export async function isEmailConfigured(source: EmailSource): Promise { - const db = getDb(source) - const opts = await loadEmailOptions(db) - if (!isEmailEnabledOption(opts)) return false - - try { - await getEmailConfig(source) - return true - } catch (error) { - if ( - error instanceof Error && - (error.message.includes('Email provider not configured') || error.message.includes('Email sender not configured')) - ) { - return false - } - throw error - } -} - -async function sendViaSmtp(from: string, smtp: SmtpConfig, message: EmailMessage): Promise { - // 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 { - 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 | undefined, from: string, message: EmailMessage): Promise { - 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 async function sendEmail(source: EmailSource, message: EmailMessage): Promise { - if (!(await isEmailConfigured(source))) { - throw new Error('Email is disabled') - } - const config = await getEmailConfig(source) - if (config.provider === 'smtp') return sendViaSmtp(config.from, config.smtp, message) - if (config.provider === 'http') return sendViaHttp(config.from, config.http, message) - return sendViaCloudflare(getPlatform(source), config.from, message) -} diff --git a/server/services/image-hosting.ts b/server/services/image-hosting.ts deleted file mode 100644 index 570eda29..00000000 --- a/server/services/image-hosting.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { and, asc, eq, gt, isNotNull, like, sql } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import type { AllowedImageMime } from '../../shared/schemas' -import type { ImageHosting } from '../../shared/types' -import { imageHostingConfigs, imageHostings } from '../db/schema' -import { mimeToExt } from '../lib/mime-utils' -import type { Database } from '../platform/interface' -import { reconcileStorageUsage, StorageQuotaExceededError, withStorageUsageReservation } from './storage-usage' - -// ── Token-based redirect helpers (used by /r/:token route) ─────────────────── - -export interface ImageResolution { - image: ImageHosting - refererAllowlist: string[] -} - -export async function resolveActiveImageByToken(db: Database, token: string): Promise { - const rows = await db.select().from(imageHostings).where(eq(imageHostings.token, token)).limit(1) - if (rows.length === 0) return null - - const row = rows[0] - if (row.status !== 'active') return null - - const configRows = await db - .select() - .from(imageHostingConfigs) - .where(eq(imageHostingConfigs.orgId, row.orgId)) - .limit(1) - - const config = configRows[0] ?? null - const refererAllowlist = config?.refererAllowlist ? (JSON.parse(config.refererAllowlist) as string[]) : [] - - return { - image: row as unknown as ImageHosting, - refererAllowlist, - } -} - -export async function resolveCustomDomain(db: Database, host: string): Promise { - 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 -} - -export async function getImageByOrgPath(db: Database, orgId: string, path: string): Promise { - 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.length === 0) return null - return rows[0] as unknown as ImageHosting -} - -export async function incrementAccessCount(db: Database, id: string): Promise { - await db.run( - sql`UPDATE image_hostings SET access_count = access_count + 1, last_accessed_at = ${Date.now()} WHERE id = ${id}`, - ) -} - -// ── CRUD service types and helpers ──────────────────────────────────────────── - -export type ImageHostingRow = typeof imageHostings.$inferSelect -export type ImageHostingConfigRow = typeof imageHostingConfigs.$inferSelect - -const PATH_PATTERN = /^[a-zA-Z0-9._/-]+$/ -const MAX_DEPTH = 5 -const MAX_PATH_LENGTH = 256 -const MAX_COLLISION_RETRIES = 5 - -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 -} - -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') -} - -async function resolveUniquePath(db: Database, orgId: string, requestedPath: string): Promise { - 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}` -} - -export async function getImageHostingConfig(db: Database, orgId: string): Promise { - const rows = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)) - return rows[0] ?? null -} - -export interface CreateImageInput { - orgId: string - path: string - mime: AllowedImageMime - size: number - storageId: string - status: 'draft' | 'active' -} - -export async function createImageHosting(db: Database, input: CreateImageInput): Promise { - 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(db, 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 row -} - -export async function getImageHosting(db: Database, id: string, orgId: string): Promise { - const rows = await db - .select() - .from(imageHostings) - .where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId))) - return rows[0] ?? null -} - -export interface ListImagesOptions { - pathPrefix?: string - cursor?: string - limit: number -} - -export async function listImageHostings( - db: Database, - orgId: string, - opts: ListImagesOptions, -): Promise<{ items: ImageHostingRow[]; nextCursor: string | null }> { - 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, nextCursor } -} - -export async function confirmImageHosting( - db: Database, - id: string, - orgId: string, -): Promise<{ row: ImageHostingRow | null; quotaExceeded?: boolean }> { - try { - const existing = await getImageHosting(db, id, orgId) - if (!existing) return { row: null } - if (existing.status !== 'draft') return { row: null } - - const bytes = existing.size - return await withStorageUsageReservation(db, { orgId, storageId: existing.storageId, bytes }, async () => { - 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 }) - - if (updated.length === 0) { - throw new Error('CONFIRM_IMAGE_RACE') - } - - return { row: { ...existing, status: 'active' } } - }) - } catch (error) { - if (error instanceof StorageQuotaExceededError) return { row: null, quotaExceeded: true } - if (error instanceof Error && error.message === 'CONFIRM_IMAGE_RACE') return { row: null } - throw error - } -} - -export async function deleteImageHosting(db: Database, id: string, orgId: string): Promise { - const existing = await getImageHosting(db, id, orgId) - if (!existing) return null - - await db.delete(imageHostings).where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId))) - - if (existing.status === 'active' && existing.size > 0) { - await reconcileStorageUsage(db, orgId, [existing.storageId]) - } - - return existing -} - -export function buildImageUrl(config: ImageHostingConfigRow | null, path: string, tokenUrl: string): string { - if (config?.customDomain && config.domainVerifiedAt) { - return `https://${config.customDomain}/${path}` - } - return tokenUrl -} - -export function deriveDefaultPath(filename: string, mime: string): string { - const ext = mimeToExt(mime as AllowedImageMime) - if (!filename || filename === 'blob') return `image-${nanoid(8)}.${ext}` - // Strip path separators from the filename for safety - const safe = filename.replace(/[/\\]/g, '_') - return safe -} diff --git a/server/services/image-upload.test.ts b/server/services/image-upload.test.ts deleted file mode 100644 index 1b8fd6e7..00000000 --- a/server/services/image-upload.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { Platform } from '../platform/interface' -import { deletePublicImageVariants, isImageMime, uploadPublicImage } 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; publicUrl?: string; dbStorage?: Any; dbStorageThrows?: boolean } = {}, -): Platform { - return { - db: { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => ({ - limit: async () => - opts.dbStorageThrows - ? (() => { - throw new Error('no storage') - })() - : opts.dbStorage - ? [opts.dbStorage] - : [], - }), - }), - }), - }), - } as Any, - getEnv: (key) => (key === 'PUBLIC_IMAGES_URL' ? opts.publicUrl : undefined), - getBinding: (key: string) => (key === 'PUBLIC_IMAGES' && opts.r2 ? (opts.r2 as unknown as T) : undefined), - } -} - -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 platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' }) - - const result = await 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 platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' }) - - const result = await 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 platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev/' }) - - const result = await 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 platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' }) - - const result = await 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 platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' }) - - const result = await 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 platform = mockPlatform({ publicUrl: 'https://pub-abc.r2.dev', dbStorageThrows: true }) - - const result = await 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 platform = mockPlatform({ r2, dbStorageThrows: true }) - - const result = await 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 DB storage is available', async () => { - const platform = mockPlatform({ dbStorageThrows: true }) - - const result = await uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png')) - - expect(result.ok).toBe(false) - if (!result.ok) expect(result.status).toBe(503) - }) -}) - -describe('deletePublicImageVariants — R2 binding path', () => { - it('deletes all 3 mime variants via R2 binding', async () => { - const r2 = mockR2Bucket() - const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' }) - - await 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('is a no-op when no backend is configured', async () => { - const platform = mockPlatform({ dbStorageThrows: true }) - await expect(deletePublicImageVariants(platform, '_system/avatars', 'u1')).resolves.toBeUndefined() - }) -}) diff --git a/server/services/image-upload.ts b/server/services/image-upload.ts deleted file mode 100644 index c82f2d6c..00000000 --- a/server/services/image-upload.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { mimeToExt } from '../lib/mime-utils' -import type { Platform } from '../platform/interface' -import { S3Service } from './s3' -import { type Storage as S3Storage, selectStorage } from './storage' - -const s3 = new S3Service() - -export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp'] as const -export type ImageMime = (typeof IMAGE_MIMES)[number] -export const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2 MiB - -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)}` -} - -export type ImageUploadResult = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string } - -// 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 - delete(key: string): Promise -} - -type Backend = - | { kind: 'r2'; bucket: R2BucketLike; publicUrlBase: string } - | { kind: 's3'; storage: S3Storage } - | { kind: 'none' } - -// 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 { - const r2 = platform.getBinding('PUBLIC_IMAGES') - const publicUrl = platform.getEnv('PUBLIC_IMAGES_URL') - if (r2 && publicUrl) { - return { kind: 'r2', bucket: r2, publicUrlBase: publicUrl.replace(/\/$/, '') } - } - try { - const storage = await selectStorage(platform.db, 'public') - return { kind: 's3', storage } - } catch { - return { kind: 'none' } - } -} - -/** - * Stream-proxy a File to the workspace's public image backend and return the - * permanent public URL. Shared by /api/me/avatar and /api/teams/:id/logo — - * validates mime + size, selects backend (R2 binding on CF / S3 fallback), - * constructs the key as `/.`, PUTs, returns URL. - */ -export async function uploadPublicImage( - platform: Platform, - prefix: string, - id: string, - file: File, -): Promise { - 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. - */ -export async function deletePublicImageVariants(platform: Platform, prefix: string, id: string): Promise { - 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) - } -} diff --git a/server/services/invite.ts b/server/services/invite.ts deleted file mode 100644 index 7f096052..00000000 --- a/server/services/invite.ts +++ /dev/null @@ -1,89 +0,0 @@ -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' - -export type InviteCode = typeof inviteCodes.$inferSelect - -const generateCode = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8) - -export async function generateInviteCodes( - db: Database, - adminUserId: string, - quantity: number, - expiresAt?: Date, -): Promise { - const now = new Date() - const rows: InviteCode[] = 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 -} - -export async function validateInviteCode(db: Database, code: string): Promise<{ valid: boolean; error?: string }> { - 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 } -} - -export async function redeemInviteCode( - db: Database, - code: string, - userId: string, -): Promise<'ok' | 'not_found' | 'already_used' | 'expired'> { - 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' -} - -export async function listInviteCodes( - db: Database, - page: number, - pageSize: number, -): Promise<{ items: InviteCode[]; total: number }> { - 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 } -} - -export async function deleteInviteCode(db: Database, codeId: string): Promise<'ok' | 'not_found' | 'already_used'> { - 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' -} diff --git a/server/services/matter-name-conflict.ts b/server/services/matter-name-conflict.ts deleted file mode 100644 index dd85c6d1..00000000 --- a/server/services/matter-name-conflict.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { and, eq, ne, sql } from 'drizzle-orm' -import { DirType, ObjectStatus } from '../../shared/constants' -import { matters } from '../db/schema' -import type { Database } from '../platform/interface' -import { recordActivity } from './activity' - -export type ConflictStrategy = 'fail' | 'rename' | 'replace' - -export class NameConflictError extends Error { - constructor( - public readonly conflictingName: string, - public readonly conflictingId: string, - ) { - super(`An item named '${conflictingName}' already exists in this location`) - this.name = 'NameConflictError' - } -} - -type MatterRow = typeof matters.$inferSelect - -/** - * Finds the active sibling that would collide with `name` under `parent` in `orgId`. - * Matching is case-insensitive (mirrors the DB's partial unique index on LOWER(name)). - * `excludeId` lets rename/move skip the row being modified. - */ -export async function findActiveConflict( - db: Database, - orgId: string, - parent: string, - name: string, - excludeId?: string, -): Promise { - 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] ?? null -} - -/** - * 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. - */ -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)}` -} - -async function findAvailableName( - db: Database, - orgId: string, - parent: string, - name: string, - excludeId: string | undefined, -): Promise { - for (let i = 1; i <= 999; i++) { - const candidate = suggestRenamed(name, i) - const conflict = await findActiveConflict(db, orgId, parent, candidate, excludeId) - if (!conflict) return candidate - } - throw new Error('Too many name conflicts to auto-rename') -} - -export interface ResolveOptions { - /** Exclude a specific row from the conflict check (rename/move cases). */ - excludeId?: string - /** Incoming item is a folder — replace is disabled for folders. */ - isFolder?: boolean - /** Activity log will record a 'replace' event for this user when replace trashes a row. */ - userId?: string -} - -/** - * Planned resolution of a name conflict. Callers can inspect the plan, run their - * own preconditions (quota, permissions), and commit it via `commitConflictPlan`. - */ -export interface ConflictPlan { - finalName: string - /** Row to trash when committing a 'replace' plan; null otherwise. */ - toTrash: MatterRow | null -} - -/** - * 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). - */ -export async function planConflictResolution( - db: Database, - orgId: string, - parent: string, - name: string, - strategy: ConflictStrategy, - options: ResolveOptions = {}, -): Promise { - const existing = await findActiveConflict(db, 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(db, orgId, parent, name, options.excludeId) - return { finalName: renamed, toTrash: null } -} - -/** Execute the side effects of a plan (trash the replaced row, log activity). */ -export async function commitConflictPlan( - db: Database, - orgId: string, - plan: ConflictPlan, - userId?: string, -): Promise { - if (plan.toTrash) { - await trashForReplace(db, 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. - */ -export async function applyConflictResolution( - db: Database, - orgId: string, - parent: string, - name: string, - strategy: ConflictStrategy, - options: ResolveOptions = {}, -): Promise { - const plan = await planConflictResolution(db, orgId, parent, name, strategy, options) - await commitConflictPlan(db, orgId, plan, options.userId) - return plan.finalName -} - -async function trashForReplace( - db: Database, - orgId: string, - existing: MatterRow, - userId: string | undefined, -): Promise { - 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 recordActivity(db, { - orgId, - userId, - action: 'replace', - targetType: 'file', - targetId: existing.id, - targetName: existing.name, - }) - } -} diff --git a/server/services/matter.ts b/server/services/matter.ts deleted file mode 100644 index 2f85c5cb..00000000 --- a/server/services/matter.ts +++ /dev/null @@ -1,642 +0,0 @@ -import type { SQL } from 'drizzle-orm' -import { and, asc, count, desc, eq, inArray, isNotNull, like, lt, or, sql } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { DirType } from '../../shared/constants' -import { matters } from '../db/schema' -import type { Database } from '../platform/interface' -import { recordActivity } from './activity' -import { - applyConflictResolution, - type ConflictStrategy, - commitConflictPlan, - planConflictResolution, -} from './matter-name-conflict' -import { reconcileStorageUsage, StorageQuotaExceededError, withStorageUsageReservation } from './storage-usage' - -export type Matter = typeof matters.$inferSelect - -interface CreateMatterInput { - orgId: string - userId?: string - name: string - type: string - size?: number - dirtype?: number - parent?: string - object: string - storageId: string - status: string - /** How to handle name collision with an existing active sibling. Default 'fail'. */ - onConflict?: ConflictStrategy -} - -export async function createMatter(db: Database, input: CreateMatterInput): Promise { - 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(db, 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(db, input.orgId, plan, input.userId) - } - const finalName = plan.finalName - - const row: Matter = { - 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 recordActivity(db, { - orgId: input.orgId, - userId: input.userId, - action: isFolder ? 'create' : 'upload', - targetType: isFolder ? 'folder' : 'file', - targetId: row.id, - targetName: row.name, - }) - } - - return row -} - -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 - } -} - -interface ListFilters { - parent?: string - status: string - page: number - pageSize: number - typeFilter?: string - search?: string -} - -export async function listMatters( - db: Database, - orgId: string, - filters: ListFilters, -): Promise<{ items: Matter[]; total: number; page: number; pageSize: number }> { - const offset = (filters.page - 1) * filters.pageSize - if (filters.status === 'trashed' && !filters.search && !filters.typeFilter) { - const roots = await listTrashedRoots(db, 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, total, page: filters.page, pageSize: filters.pageSize } -} - -export async function getMatter(db: Database, id: string, orgId: string): Promise { - const rows = await db - .select() - .from(matters) - .where(and(eq(matters.id, id), eq(matters.orgId, orgId))) - return rows[0] ?? null -} - -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}` -} - -export async function updateMatter( - db: Database, - id: string, - orgId: string, - input: { name?: string; parent?: string; onConflict?: ConflictStrategy }, - userId?: string, -): Promise { - const existing = await getMatter(db, 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(db, 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(db, 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 recordActivity(db, { - orgId, - userId, - action: 'rename', - targetType, - targetId: id, - targetName: newName, - metadata: { from: existing.name }, - }) - } - if (newParent !== existing.parent) { - await recordActivity(db, { - orgId, - userId, - action: 'move', - targetType, - targetId: id, - targetName: newName, - metadata: { from: existing.parent, to: newParent }, - }) - } - } - - return updated -} - -async function cascadeParentPath(db: Database, orgId: string, oldPath: string, newPath: string): Promise { - // 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))) -} - -export async function confirmUpload( - db: Database, - id: string, - orgId: string, - opts: { - onConflict?: ConflictStrategy - userId?: string - teamQuotaEnabled?: boolean - /** - * Overwrites the file being replaced: hard-purge it (delete row, S3 object, - * shares). With it, a 'replace' frees the incumbent's quota so the upload is - * charged as a net-size change — matching normal overwrite semantics. Without - * it, replace falls back to trashing the incumbent. - */ - purgeReplaced?: (incumbent: Matter) => Promise - } = {}, -): Promise<{ matter: Matter | null; quotaExceeded?: boolean }> { - try { - const existing = await getMatter(db, id, orgId) - if (!existing) return { matter: null } - if (existing.status !== 'draft') return { matter: null } - - // Plan the overwrite now (side-effect-free). createMatter deferred it for - // draft 'replace', so the incumbent is still active and the quota check - // below accounts for its bytes being freed. The DB's partial unique index - // fires on the status update as a final safety net against concurrent confirms. - const plan = await planConflictResolution(db, orgId, existing.parent, existing.name, opts.onConflict ?? 'fail', { - excludeId: existing.id, - isFolder: false, - userId: opts.userId, - }) - - const bytes = existing.size ?? 0 - // Purging the incumbent frees its bytes, so only the net size increase needs - // headroom; a final reconcile then sets usage to the exact active+trashed sum. - const overwrites = plan.toTrash != null && opts.purgeReplaced != null - const reserveBytes = overwrites ? Math.max(0, bytes - (plan.toTrash?.size ?? 0)) : bytes - - return await withStorageUsageReservation( - db, - { orgId, storageId: existing.storageId, bytes: reserveBytes, teamQuotaEnabled: opts.teamQuotaEnabled ?? true }, - async () => { - // Quota reserved — now safe to execute the overwrite (if any). - if (plan.toTrash && opts.purgeReplaced) { - await opts.purgeReplaced(plan.toTrash) - if (opts.userId) { - await recordActivity(db, { - orgId, - userId: opts.userId, - action: 'replace', - targetType: 'file', - targetId: plan.toTrash.id, - targetName: plan.toTrash.name, - }) - } - } else { - await commitConflictPlan(db, orgId, plan, opts.userId) - } - - const now = new Date() - const updated = await db - .update(matters) - .set({ name: plan.finalName, status: 'active', updatedAt: now }) - .where(and(eq(matters.id, id), eq(matters.orgId, orgId), eq(matters.status, 'draft'))) - .returning({ id: matters.id }) - - if (updated.length === 0) { - throw new Error('CONFIRM_UPLOAD_RACE') - } - - // The purge reconciled usage before this row became active; recompute - // once more so the new file's bytes are reflected. - if (overwrites) await reconcileStorageUsage(db, orgId, [existing.storageId]) - - const confirmed = { ...existing, name: plan.finalName, status: 'active', updatedAt: now } - - if (opts.userId) { - await recordActivity(db, { - orgId, - userId: opts.userId, - action: 'upload_confirm', - targetType: 'file', - targetId: confirmed.id, - targetName: confirmed.name, - }) - } - - return { matter: confirmed } - }, - ) - } catch (error) { - if (error instanceof StorageQuotaExceededError) return { matter: null, quotaExceeded: true } - if (error instanceof Error && error.message === 'CONFIRM_UPLOAD_RACE') return { matter: null } - throw error - } -} - -export async function copyMatter( - db: Database, - source: Matter, - targetParent: string, - newObject: string, - opts: { onConflict?: ConflictStrategy; userId?: string } = {}, -): Promise { - 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( - db, - source.orgId, - targetParent, - source.name, - opts.onConflict ?? 'rename', - { isFolder, userId: opts.userId }, - ) - - const row: Matter = { - 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 recordActivity(db, { - 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 row -} - -export async function deleteMatter(db: Database, id: string, orgId: string): Promise { - const existing = await getMatter(db, id, orgId) - if (!existing) return null - - await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId))) - return existing -} - -export async function cancelDraftMatter( - db: Database, - id: string, - orgId: string, - userId?: string, -): Promise { - const existing = await getMatter(db, 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 recordActivity(db, { - orgId, - userId, - action: 'upload_cancel', - targetType: 'file', - targetId: existing.id, - targetName: existing.name, - }) - } - - return existing -} - -// ─── Batch Operations ──────────────────────────────────────────────────────── - -export async function getMatters(db: Database, orgId: string, ids: string[]): Promise { - if (ids.length === 0) return [] - - return db - .select() - .from(matters) - .where(and(eq(matters.orgId, orgId), inArray(matters.id, ids))) -} - -function getDescendants(db: Database, orgId: string, folderPath: string): Promise { - return db - .select() - .from(matters) - .where(and(eq(matters.orgId, orgId), descendantParentCondition(folderPath))) -} - -function getDirectChildren(db: Database, orgId: string, folderPath: string): Promise { - return db - .select() - .from(matters) - .where(and(eq(matters.orgId, orgId), eq(matters.parent, folderPath))) -} - -// ─── Recycle Bin ───────────────────────────────────────────────────────────── - -export async function trashMatter(db: Database, orgId: string, id: string, userId?: string): Promise { - const existing = await getMatter(db, 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(db, orgId, path) - const descendants = await getDescendants(db, 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 recordActivity(db, { - orgId, - userId, - action: 'delete', - targetType: existing.dirtype !== DirType.FILE ? 'folder' : 'file', - targetId: existing.id, - targetName: existing.name, - }) - } - - return trashed -} - -export async function restoreMatter( - db: Database, - orgId: string, - id: string, - userId?: string, - onConflict: ConflictStrategy = 'fail', -): Promise { - const existing = await getMatter(db, 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(db, 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(db, orgId, path) - const descendants = await getDescendants(db, 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(db, 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 recordActivity(db, { - orgId, - userId, - action: 'restore', - targetType: isFolder ? 'folder' : 'file', - targetId: existing.id, - targetName: finalName, - }) - } - - return restored -} - -export async function collectForPurge(db: Database, orgId: string, id: string): Promise -export async function collectForPurge(db: Database, orgId: string, existing: Matter): Promise -export async function collectForPurge( - db: Database, - orgId: string, - idOrMatter: string | Matter, -): Promise { - const existing = typeof idOrMatter === 'string' ? await getMatter(db, 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(db, orgId, path) - const descendants = await getDescendants(db, orgId, path) - return [existing, ...children, ...descendants] -} - -export async function purgeMatters(db: Database, orgId: string, ids: string[]): Promise { - for (const id of ids) { - await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId))) - } -} - -/** Distinct orgIds that hold at least one trashed matter older than the cutoff (epoch ms). */ -export async function listOrgIdsWithExpiredTrash(db: Database, cutoff: number): Promise { - 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) -} - -export async function listTrashedRoots(db: Database, orgId: string): Promise { - 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() - }) -} diff --git a/server/services/notification.ts b/server/services/notification.ts deleted file mode 100644 index 101415d3..00000000 --- a/server/services/notification.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { NotificationType } from '@shared/types' -import { and, count, desc, eq, isNull } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { notifications } from '../db/schema' -import type { Database } from '../platform/interface' - -export type Notification = typeof notifications.$inferSelect - -export type CreateNotificationInput = { - userId: string - type: NotificationType - title: string - body?: string - refType?: string - refId?: string - metadata?: string -} - -export async function createNotification(db: Database, input: CreateNotificationInput): Promise { - const row: Notification = { - 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 row -} - -export type ListNotificationsResult = { - items: Notification[] - total: number - unreadCount: number -} - -export async function listNotifications( - db: Database, - userId: string, - opts: { page: number; pageSize: number; unreadOnly?: boolean }, -): Promise { - 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, - total: totalRows[0]?.count ?? 0, - unreadCount: unreadRows[0]?.count ?? 0, - } -} - -export async function markAsRead(db: Database, userId: string, id: string): Promise { - 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 -} - -export async function markAllAsRead(db: Database, userId: string): Promise<{ count: number }> { - 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 } -} - -export async function unreadCount(db: Database, userId: string): Promise { - const rows = await db - .select({ count: count() }) - .from(notifications) - .where(and(eq(notifications.userId, userId), isNull(notifications.readAt))) - - return rows[0]?.count ?? 0 -} diff --git a/server/services/object-upload-sessions.ts b/server/services/object-upload-sessions.ts deleted file mode 100644 index 281d704b..00000000 --- a/server/services/object-upload-sessions.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { PatchObjectUploadSessionInput } from '@shared/schemas' -import type { ObjectUploadSession } from '@shared/types' -import { and, eq } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { objectUploadSessions } from '../db/schema' -import type { Database } from '../platform/interface' -import type { S3Service } from './s3' -import type { Storage as S3Storage } from './storage' - -const DEFAULT_PART_SIZE = 16 * 1024 * 1024 -const SESSION_TTL_MS = 24 * 60 * 60 * 1000 - -export class ObjectUploadSessionError extends Error { - constructor( - readonly code: 'not_found' | 'invalid_state' | 'storage_failure', - message?: string, - ) { - super(message ?? code) - this.name = 'ObjectUploadSessionError' - } -} - -type SessionRow = typeof objectUploadSessions.$inferSelect - -export async function createObjectUploadSession( - db: Database, - s3: S3Service, - params: { - orgId: string - objectId: string - storage: S3Storage - storageKey: string - contentType: string - partSize?: number - actorId: string - }, -): Promise { - const now = new Date() - let uploadId: string - try { - uploadId = await s3.createMultipartUpload(params.storage, params.storageKey, params.contentType) - } catch (error) { - throw new ObjectUploadSessionError( - 'storage_failure', - `Storage multipart upload failed: ${(error as Error).message}`, - ) - } - const row: typeof objectUploadSessions.$inferInsert = { - id: nanoid(), - orgId: params.orgId, - objectId: params.objectId, - storageId: params.storage.id, - storageKey: params.storageKey, - uploadId, - partSize: params.partSize ?? DEFAULT_PART_SIZE, - status: 'active', - createdBy: params.actorId, - expiresAt: new Date(now.getTime() + SESSION_TTL_MS), - createdAt: now, - updatedAt: now, - } - await db.insert(objectUploadSessions).values(row) - return toDto(row as SessionRow) -} - -export async function getObjectUploadSession( - db: Database, - orgId: string, - objectId: string, - id: string, -): Promise { - const row = await getRow(db, orgId, objectId, id) - if (!row) throw new ObjectUploadSessionError('not_found') - return toDto(row) -} - -export async function presignObjectUploadParts( - db: Database, - s3: S3Service, - params: { - orgId: string - objectId: string - sessionId: string - storage: S3Storage - partNumbers: number[] - }, -): Promise<{ uploadId: string; partSize: number; parts: Array<{ partNumber: number; url: string }> }> { - const row = await getRow(db, params.orgId, params.objectId, params.sessionId) - if (!row) throw new ObjectUploadSessionError('not_found') - if (row.status !== 'active' || row.expiresAt.getTime() <= Date.now()) { - throw new ObjectUploadSessionError('invalid_state') - } - const parts = await Promise.all( - params.partNumbers.map(async (partNumber) => ({ - partNumber, - url: await s3.presignUploadPart(params.storage, row.storageKey, row.uploadId, partNumber), - })), - ) - return { uploadId: row.uploadId, partSize: row.partSize, parts } -} - -export async function patchObjectUploadSession( - db: Database, - s3: S3Service, - params: { - orgId: string - objectId: string - sessionId: string - storage: S3Storage - input: PatchObjectUploadSessionInput - }, -): Promise { - const row = await getRow(db, params.orgId, params.objectId, params.sessionId) - if (!row) throw new ObjectUploadSessionError('not_found') - if (row.status !== 'active') throw new ObjectUploadSessionError('invalid_state') - const now = new Date() - if (params.input.action === 'complete') { - try { - await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts) - } catch (error) { - throw new ObjectUploadSessionError( - 'storage_failure', - `Storage multipart upload complete failed: ${(error as Error).message}`, - ) - } - await db - .update(objectUploadSessions) - .set({ status: 'completed', updatedAt: now }) - .where(eq(objectUploadSessions.id, row.id)) - } else { - try { - await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId) - } catch (error) { - throw new ObjectUploadSessionError( - 'storage_failure', - `Storage multipart upload abort failed: ${(error as Error).message}`, - ) - } - await db - .update(objectUploadSessions) - .set({ status: 'aborted', updatedAt: now }) - .where(eq(objectUploadSessions.id, row.id)) - } - return getObjectUploadSession(db, params.orgId, params.objectId, params.sessionId) -} - -async function getRow(db: Database, orgId: string, objectId: string, id: string): Promise { - const rows = await db - .select() - .from(objectUploadSessions) - .where( - and( - eq(objectUploadSessions.id, id), - eq(objectUploadSessions.orgId, orgId), - eq(objectUploadSessions.objectId, objectId), - ), - ) - .limit(1) - return rows[0] ?? null -} - -function toDto(row: SessionRow): ObjectUploadSession { - return { - id: row.id, - objectId: row.objectId, - uploadId: row.uploadId, - partSize: row.partSize, - status: row.status as ObjectUploadSession['status'], - expiresAt: row.expiresAt.toISOString(), - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} diff --git a/server/services/org-entitlements.ts b/server/services/org-entitlements.ts deleted file mode 100644 index a620191e..00000000 --- a/server/services/org-entitlements.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { and, desc, eq } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { organization } from '../db/auth-schema' -import { orgQuotaEntitlements } from '../db/schema' -import type { Database } from '../platform/interface' -import type { QuotaEntitlementItem, UserOperationFailure } from './user' - -// Org-scoped admin entitlement operations. These work for any org — teams and -// personal spaces alike; the user-scoped wrappers in user.ts resolve a user's -// personal org first and then delegate here. - -export 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 } -} - -export 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 } -} - -export async function grantOrgEntitlement( - db: Database, - input: { - adminUserId: string - orgId: string - resourceType: 'storage' - bytes: number - expiresAt?: Date | null - note?: string | null - }, -): 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] } -} - -export async function updateOrgEntitlement( - db: Database, - input: { - adminUserId: string - orgId: string - entitlementId: string - bytes?: number - expiresAt?: Date | null - note?: string | null - }, -): 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] } -} - -export 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 { - 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 { - const base = existing ? (JSON.parse(existing) as Record) : {} - return JSON.stringify({ ...base, ...patch }) -} diff --git a/server/services/org.ts b/server/services/org.ts deleted file mode 100644 index d3757a4a..00000000 --- a/server/services/org.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { and, eq } from 'drizzle-orm' -import { member, organization } from '../db/auth-schema' -import type { Database } from '../platform/interface' - -// Find the user's personal org, if they still belong to it. The personal org -// slug is a deterministic `personal-${user.id}` written by createAuth's -// user.create.after hook, so we filter on the indexed UNIQUE slug column and -// then verify the member row still exists. Verifying membership is load- -// bearing: an admin can revoke a user's access by deleting the member row -// without deleting the org, and the caller must treat that user as orphaned. -export async function findPersonalOrg(db: Database, userId: string): Promise { - 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 -} - -// Return the user's role in the given org, or null if they are not a member. -export async function getMemberRole(db: Database, orgId: string, userId: string): Promise { - 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 -} - -const ROLE_LEVELS: Record = { owner: 3, editor: 2, viewer: 1, member: 1 } - -// Whether the user may read content of the given org: any membership role, or -// the org is the user's own personal org. Never grants access to another -// user's personal org. -export async function canReadOrg(db: Database, userId: string, orgId: string): Promise { - const role = await getMemberRole(db, orgId, userId) - if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.viewer - return orgId === (await findPersonalOrg(db, userId)) -} - -// Whether the user may write content into the given org: editor or owner -// membership, or the org is 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. -export async function canWriteToOrg(db: Database, userId: string, orgId: string): Promise { - const role = await getMemberRole(db, orgId, userId) - if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.editor - return orgId === (await findPersonalOrg(db, userId)) -} - -// Personal orgs use a deterministic slug `personal-${userId}`. Checking the -// slug is sufficient — no additional query is needed. -export async function isPersonalOrg(db: Database, orgId: string): Promise { - const rows = await db - .select({ slug: organization.slug }) - .from(organization) - .where(eq(organization.id, orgId)) - .limit(1) - - return (rows[0]?.slug ?? '').startsWith('personal-') -} diff --git a/server/services/profile.ts b/server/services/profile.ts deleted file mode 100644 index 7669cd37..00000000 --- a/server/services/profile.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { eq } from 'drizzle-orm' -import { user } from '../db/auth-schema' -import type { Database } from '../platform/interface' - -export interface PublicUser { - username: string - name: string - image: string | null -} - -export async function getUserByUsername(db: Database, username: string): Promise { - 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 } -} - -export function buildBreadcrumb(dir: string): string[] { - if (!dir) return [] - return dir.split('/') -} diff --git a/server/services/purge.ts b/server/services/purge.ts deleted file mode 100644 index 885959fe..00000000 --- a/server/services/purge.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { DirType } from '../../shared/constants' -import type { Database } from '../platform/interface' -import { type Matter, purgeMatters } from './matter' -import { S3Service } from './s3' -import { cascadeDeleteByMatter } from './share' -import { getStorage, type Storage as S3Storage } from './storage' -import { reconcileStorageUsage } from './storage-usage' - -const s3 = new S3Service() - -export async function purgeRecursively(db: Database, orgId: string, matters: Matter[]): Promise { - const keysByStorage = new Map() - const bytesByStorage = new Map() - let totalBytes = 0 - - for (const m of matters) { - const size = m.size ?? 0 - if (m.dirtype === DirType.FILE && size > 0) { - bytesByStorage.set(m.storageId, (bytesByStorage.get(m.storageId) ?? 0) + size) - totalBytes += size - } - if (!m.object) continue - let entry = keysByStorage.get(m.storageId) - if (!entry) { - const storage = await getStorage(db, m.storageId) - entry = { storage, keys: [] } - keysByStorage.set(m.storageId, entry) - } - entry.keys.push(m.object) - } - - for (const { storage, keys } of keysByStorage.values()) { - if (storage && keys.length > 0) await s3.deleteObjects(storage, keys) - } - - for (const m of matters) { - await cascadeDeleteByMatter(db, m.id) - } - - await purgeMatters( - db, - orgId, - matters.map((m) => m.id), - ) - if (totalBytes > 0) await reconcileStorageUsage(db, orgId, bytesByStorage.keys()) - return matters.length -} diff --git a/server/services/remote-download-usage.ts b/server/services/remote-download-usage.ts deleted file mode 100644 index 892f51b8..00000000 --- a/server/services/remote-download-usage.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { asc, eq, inArray } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { z } from 'zod' -import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' -import { remoteDownloadUsageReports } from '../db/schema' -import { hasFeature, loadBindingState } from '../licensing/has-feature' -import { loadActiveLicenseBinding } from '../licensing/license-state' -import type { Database, Platform } from '../platform/interface' -import { createBoundCloudClient, requestCloudJson } from './licensing-cloud' - -export class RemoteDownloadBillingBlockedError extends Error { - constructor() { - super('insufficient_credits') - this.name = 'RemoteDownloadBillingBlockedError' - } -} - -const usageResponseSchema = z.object({ - accepted: z.boolean(), - duplicate: z.boolean().optional(), - eventId: z.string().min(1), -}) - -type RemoteDownloadUsageStatus = 'pending' | 'reported' | 'skipped_unbound' | 'blocked' | 'failed' -type RemoteDownloadUsageReport = typeof remoteDownloadUsageReports.$inferSelect - -export async function reportRemoteDownloadUnit(params: { - platform: Platform - orgId: string - downloaderId: string - taskId: string - unitIndex: number - unitBytes: number - creditsPerUnit: number - enabled: boolean -}): Promise<{ status: RemoteDownloadUsageStatus; eventId: string }> { - if (!params.enabled) return { status: 'reported', eventId: '' } - if (!hasFeature('quota_store', await loadBindingState(params.platform.db))) return { status: 'reported', eventId: '' } - const eventId = `remote_download:${params.taskId}:${params.unitIndex}` - const existing = await params.platform.db - .select() - .from(remoteDownloadUsageReports) - .where(eq(remoteDownloadUsageReports.eventId, eventId)) - .limit(1) - if (existing[0]?.status === 'reported') return { status: 'reported', eventId } - if (existing[0]?.status === 'blocked') throw new RemoteDownloadBillingBlockedError() - - const now = new Date() - if (!existing[0]) { - await params.platform.db.insert(remoteDownloadUsageReports).values({ - id: nanoid(), - orgId: params.orgId, - downloaderId: params.downloaderId, - taskId: params.taskId, - eventId, - unitIndex: params.unitIndex, - unitBytes: params.unitBytes, - creditsPerUnit: params.creditsPerUnit, - status: 'pending', - error: null, - createdAt: now, - updatedAt: now, - }) - } - - const status = await syncRemoteDownloadUsageReport({ - db: params.platform.db, - cloudBaseUrl: params.platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT, - report: (await loadRemoteDownloadUsageReport(params.platform.db, eventId))!, - now, - }) - if (status === 'blocked') throw new RemoteDownloadBillingBlockedError() - return { status, eventId } -} - -export async function syncPendingRemoteDownloadUsageReports(params: { - db: Database - cloudBaseUrl: string - limit?: number - now?: Date -}): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> { - const { db, cloudBaseUrl, limit = 100, now = new Date() } = params - if (!hasFeature('quota_store', await loadBindingState(db))) - return { attempted: 0, reported: 0, blocked: 0, failed: 0 } - const binding = await loadActiveLicenseBinding(db) - if (!binding?.refreshToken || !binding.cloudStoreId) return { attempted: 0, reported: 0, blocked: 0, failed: 0 } - - const reports = await db - .select() - .from(remoteDownloadUsageReports) - .where(inArray(remoteDownloadUsageReports.status, ['pending', 'failed'])) - .orderBy(asc(remoteDownloadUsageReports.createdAt)) - .limit(limit) - - const result = { attempted: reports.length, reported: 0, blocked: 0, failed: 0 } - for (const report of reports) { - const status = await syncRemoteDownloadUsageReport({ db, cloudBaseUrl, report, now }) - result[status] += 1 - } - return result -} - -async function syncRemoteDownloadUsageReport(params: { - db: Database - cloudBaseUrl: string - report: RemoteDownloadUsageReport - now: Date -}): Promise<'reported' | 'blocked' | 'failed'> { - const { db, cloudBaseUrl, report, now } = params - const binding = await loadActiveLicenseBinding(db) - if (!binding?.refreshToken || !binding.cloudStoreId) { - await mark(db, report.eventId, 'skipped_unbound', null, now) - return 'reported' - } - - try { - const client = createBoundCloudClient(cloudBaseUrl, binding.refreshToken) - const response = await requestCloudJson( - client.stores[':storeId'].billing['usage-events'].$post({ - param: { storeId: binding.cloudStoreId }, - json: { - resource: 'remote_download', - unit: 'byte', - bytes: report.unitBytes, - eventId: report.eventId, - idempotencyKey: report.eventId, - customerId: report.orgId, - source: 'remote_download', - sourceId: report.taskId, - usageContext: { downloaderId: report.downloaderId }, - pricing: { unitQuantity: report.unitBytes, creditsPerUnit: report.creditsPerUnit }, - } as never, - }), - usageResponseSchema, - ) - if (!response.accepted) throw new Error('cloud_usage_report_rejected') - await mark(db, report.eventId, 'reported', null, now) - return 'reported' - } catch (error) { - const message = error instanceof Error ? error.message : 'cloud_usage_report_failed' - if (message === 'insufficient_credits' || message === 'overage_cap_exceeded') { - await mark(db, report.eventId, 'blocked', message, now) - return 'blocked' - } - await mark(db, report.eventId, 'failed', message, now) - return 'failed' - } -} - -async function loadRemoteDownloadUsageReport(db: Database, eventId: string) { - const rows = await db - .select() - .from(remoteDownloadUsageReports) - .where(eq(remoteDownloadUsageReports.eventId, eventId)) - .limit(1) - return rows[0] -} - -async function mark(db: Database, eventId: string, status: string, error: string | null, now: Date) { - await db - .update(remoteDownloadUsageReports) - .set({ status, error, updatedAt: now }) - .where(eq(remoteDownloadUsageReports.eventId, eventId)) -} diff --git a/server/services/share-notification.integration.test.ts b/server/services/share-notification.integration.test.ts deleted file mode 100644 index ac14762d..00000000 --- a/server/services/share-notification.integration.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { eq } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as authSchema from '../db/auth-schema.js' -import { notifications, systemOptions } from '../db/schema.js' -import * as emailService from '../services/email.js' -import { dispatchShareCreated, type RecipientInput } from '../services/share-notification.js' -import { createTestApp } from '../test/setup.js' - -type TestDb = Awaited>['db'] - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) { - const id = overrides.id ?? nanoid() - const email = overrides.email ?? `${id}@example.com` - await db.insert(authSchema.user).values({ - id, - name: 'Test User', - email, - emailVerified: false, - createdAt: new Date(), - updatedAt: new Date(), - }) - return { id, email } -} - -function makeShare( - overrides: Partial<{ - id: string - token: string - kind: 'landing' | 'direct' - expiresAt: Date | null - }> = {}, -) { - return { - id: overrides.id ?? nanoid(), - token: overrides.token ?? nanoid(10), - kind: overrides.kind ?? 'landing', - matterId: nanoid(), - orgId: nanoid(), - creatorId: nanoid(), - passwordHash: null, - expiresAt: overrides.expiresAt ?? null, - downloadLimit: null, - views: 0, - downloads: 0, - status: 'active', - createdAt: new Date(), - } -} - -async function configureEmail(db: TestDb) { - await db.insert(systemOptions).values({ - key: 'email_enabled', - value: 'true', - public: false, - }) - await db.insert(systemOptions).values({ - key: 'email_provider', - value: 'smtp', - public: false, - }) - await db.insert(systemOptions).values({ key: 'email_from', value: 'no-reply@example.com', public: false }) - await db.insert(systemOptions).values({ key: 'email_smtp_host', value: 'smtp.example.com', public: false }) - await db.insert(systemOptions).values({ key: 'email_smtp_port', value: '587', public: false }) -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe('dispatchShareCreated', () => { - beforeEach(() => { - vi.restoreAllMocks() - }) - - it('inserts a notification row when recipient has recipientUserId', async () => { - const { db } = await createTestApp() - const user = await insertUser(db) - const share = makeShare() - const recipients: RecipientInput[] = [{ recipientUserId: user.id }] - - await dispatchShareCreated(db, share, recipients, 'Alice', 'secret.pdf') - - const rows = await db.select().from(notifications).where(eq(notifications.userId, user.id)) - expect(rows).toHaveLength(1) - expect(rows[0].type).toBe('share_received') - expect(rows[0].title).toContain('Alice') - expect(rows[0].title).toContain('secret.pdf') - expect(rows[0].refType).toBe('share') - expect(rows[0].refId).toBe(share.id) - }) - - it('does not insert notification when recipient has only email (no userId)', async () => { - const { db } = await createTestApp() - const share = makeShare() - const recipients: RecipientInput[] = [{ recipientEmail: 'someone@example.com' }] - - await dispatchShareCreated(db, share, recipients, 'Bob', 'file.txt') - - const rows = await db.select().from(notifications) - expect(rows).toHaveLength(0) - }) - - it('does not send email and does not throw when email is not configured', async () => { - const { db } = await createTestApp() - const sendEmailSpy = vi.spyOn(emailService, 'sendEmail') - const share = makeShare() - const recipients: RecipientInput[] = [{ recipientEmail: 'test@example.com' }] - - // No email config in DB - await expect(dispatchShareCreated(db, share, recipients, 'Carol', 'report.docx')).resolves.toBeUndefined() - expect(sendEmailSpy).not.toHaveBeenCalled() - }) - - it('sends email when email is configured and recipient has email', async () => { - const { db } = await createTestApp() - const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) - - await configureEmail(db) - - const share = makeShare() - const recipients: RecipientInput[] = [{ recipientEmail: 'dave@example.com' }] - - await dispatchShareCreated(db, share, recipients, 'Eve', 'photo.jpg') - - expect(sendEmailSpy).toHaveBeenCalledOnce() - const callArgs = sendEmailSpy.mock.calls[0] - // sendEmail(db, message) — second arg is the message - expect(callArgs[1].to).toBe('dave@example.com') - expect(callArgs[1].subject).toContain('Eve') - expect(callArgs[1].subject).toContain('photo.jpg') - }) - - it('looks up email from user table when recipient has only recipientUserId and email is configured', async () => { - const { db } = await createTestApp() - const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) - - await configureEmail(db) - - const user = await insertUser(db, { email: 'frank@example.com' }) - const share = makeShare() - const recipients: RecipientInput[] = [{ recipientUserId: user.id }] - - await dispatchShareCreated(db, share, recipients, 'Grace', 'budget.xlsx') - - expect(sendEmailSpy).toHaveBeenCalledOnce() - const callArgs = sendEmailSpy.mock.calls[0] - expect(callArgs[1].to).toBe('frank@example.com') - }) - - it('does not throw when email send fails — logs and continues', async () => { - const { db } = await createTestApp() - const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockRejectedValue(new Error('SMTP down')) - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await configureEmail(db) - - const share = makeShare() - const recipients: RecipientInput[] = [{ recipientEmail: 'victim@example.com' }] - - // Should NOT throw despite email failure - await expect(dispatchShareCreated(db, share, recipients, 'Sender', 'file.txt')).resolves.toBeUndefined() - expect(sendEmailSpy).toHaveBeenCalledOnce() - expect(consoleErrorSpy).toHaveBeenCalled() - }) - - it('inserts in-app notifications for all recipients that have recipientUserId', async () => { - const { db } = await createTestApp() - const user1 = await insertUser(db) - const user2 = await insertUser(db) - const share = makeShare() - - const recipients: RecipientInput[] = [ - { recipientUserId: user1.id }, - { recipientUserId: user2.id }, - { recipientEmail: 'no-account@example.com' }, - ] - - await dispatchShareCreated(db, share, recipients, 'Hub', 'multi.zip') - - const rows1 = await db.select().from(notifications).where(eq(notifications.userId, user1.id)) - expect(rows1).toHaveLength(1) - - const rows2 = await db.select().from(notifications).where(eq(notifications.userId, user2.id)) - expect(rows2).toHaveLength(1) - - // No notification for email-only recipient - const allRows = await db.select().from(notifications) - expect(allRows).toHaveLength(2) - }) - - it('uses /s/{token} URL for landing shares in notification metadata', async () => { - const { db } = await createTestApp() - const user = await insertUser(db) - const share = makeShare({ kind: 'landing', token: 'abc123token' }) - - await dispatchShareCreated(db, share, [{ recipientUserId: user.id }], 'Ian', 'landing.pdf') - - const rows = await db.select().from(notifications).where(eq(notifications.userId, user.id)) - expect(rows).toHaveLength(1) - const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record - expect(metadata.token).toBe('abc123token') - expect(metadata.kind).toBe('landing') - }) - - it('uses /r/{token} URL for direct shares in notification metadata', async () => { - const { db } = await createTestApp() - const user = await insertUser(db) - const share = makeShare({ kind: 'direct', token: 'directtoken1' }) - - await dispatchShareCreated(db, share, [{ recipientUserId: user.id }], 'Jane', 'direct.mp4') - - const rows = await db.select().from(notifications).where(eq(notifications.userId, user.id)) - expect(rows).toHaveLength(1) - const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record - expect(metadata.kind).toBe('direct') - }) - - it('includes expiresAt in email body when share has an expiry date', async () => { - const { db } = await createTestApp() - const sendEmailSpy = vi.spyOn(emailService, 'sendEmail').mockResolvedValue(undefined) - - await configureEmail(db) - - const expiresAt = new Date('2026-12-31T00:00:00Z') - const share = makeShare({ expiresAt }) - const recipients: RecipientInput[] = [{ recipientEmail: 'reader@example.com' }] - - await dispatchShareCreated(db, share, recipients, 'Karl', 'expiring.pdf') - - expect(sendEmailSpy).toHaveBeenCalledOnce() - const emailHtml = sendEmailSpy.mock.calls[0][1].html - expect(emailHtml).toContain('2026-12-31') - }) - - it('handles empty recipients array without errors', async () => { - const { db } = await createTestApp() - const share = makeShare() - - await expect(dispatchShareCreated(db, share, [], 'Leo', 'empty.txt')).resolves.toBeUndefined() - - const rows = await db.select().from(notifications) - expect(rows).toHaveLength(0) - }) -}) diff --git a/server/services/share.ts b/server/services/share.ts deleted file mode 100644 index 0aafd1b7..00000000 --- a/server/services/share.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { and, count, desc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { DirType } from '../../shared/constants' -import type { CreateShareInput } from '../../shared/schemas/share' -import { matters, shareRecipients, shares } from '../db/schema' -import { hashPassword, verifyPassword as verifyPasswordHash } from '../lib/password' -import type { Database } from '../platform/interface' -import { type AtomicQuery, executeWriteTransaction } from './db-transaction' -import type { Matter } from './matter' - -export type Share = typeof shares.$inferSelect -export type ShareRecipient = typeof shareRecipients.$inferSelect -export type ShareWithDetails = Share & { - matter: { name: string; type: string; dirtype: number } - recipients: ShareRecipient[] -} -export type ShareListItem = Omit & { - matter: { name: string; type: string; dirtype: number } - recipientCount: number - creatorName?: string -} - -export function verifyPassword(share: Share, plaintext: string): boolean { - if (!share.passwordHash) return false - return verifyPasswordHash(share.passwordHash, plaintext) -} - -export function isAccessibleByUser(recipients: ShareRecipient[], userId: string): boolean { - return recipients.some((r) => r.recipientUserId === userId) -} - -export async function createShare(db: Database, input: CreateShareInput): Promise { - if (input.kind === 'direct' && input.password) throw new Error('DIRECT_NO_PASSWORD') - if (input.kind === 'direct' && input.recipients && input.recipients.length > 0) - throw new Error('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 Error('MATTER_NOT_FOUND') - if (input.kind === 'direct' && matter.dirtype !== DirType.FILE) throw new Error('DIRECT_NO_FOLDER') - - const now = new Date() - const token = input.kind === 'direct' ? `ds_${nanoid(10)}` : nanoid(10) - const share: Share = { - 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: ShareRecipient[] = 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 -} - -export type ShareResolution = - | { status: 'ok'; share: Share; matter: Matter; recipients: ShareRecipient[] } - | { status: 'not_found' | 'revoked' | 'matter_trashed' } - -export async function resolveShareByToken(db: Database, token: string): Promise { - 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 } -} - -export async function incrementViews(db: Database, shareId: string): Promise { - await db - .update(shares) - .set({ views: sql`${shares.views} + 1` }) - .where(eq(shares.id, shareId)) -} - -export async function hasDownloadsAvailable(db: Database, shareId: string): Promise { - 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 -} - -export async function incrementDownloadsAtomic( - db: Database, - 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 } -} - -export async function decrementDownloads(db: Database, shareId: string): Promise { - await db - .update(shares) - .set({ downloads: sql`CASE WHEN ${shares.downloads} > 0 THEN ${shares.downloads} - 1 ELSE 0 END` }) - .where(eq(shares.id, shareId)) -} - -export async function listShareRecipientUserIds(db: Database, shareId: string): Promise { - 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) -} - -export async function cascadeDeleteByMatter(db: Database, matterId: string): Promise { - 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)), - ]) -} - -export async function getShareCreatorByToken(db: Database, token: string): Promise { - const rows = await db.select({ creatorId: shares.creatorId }).from(shares).where(eq(shares.token, token)) - return rows[0]?.creatorId ?? null -} - -export async function revokeShareByToken(db: Database, token: string, creatorId: string): Promise { - 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 -} - -export async function listSharesForApi( - db: Database, - 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 } -} - -// Shares directed at the user, matched by user id or by the email the share -// was addressed to. This is an inbox of share links — the items still live in -// (and are revocable by) the sharer's space. -export async function listReceivedSharesForApi( - db: Database, - 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`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`(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 } -} diff --git a/server/services/site-public-origin.ts b/server/services/site-public-origin.ts deleted file mode 100644 index 13a43407..00000000 --- a/server/services/site-public-origin.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { eq } from 'drizzle-orm' -import { systemOptions } from '../db/schema' -import type { Database } from '../platform/interface' - -export const SITE_PUBLIC_ORIGIN_KEY = 'site_public_origin' - -// Resolved origin, cached for the lifetime of the isolate/process. Only the -// settled value is cached — never a pending promise, which on Cloudflare -// Workers would hang any request that awaited it after its creating request -// ended. One worker serves one site, so a single slot is enough; staleness is -// harmless because the middleware only acts when the row is first created. -let cachedOrigin: string | null = null - -export function resetSitePublicOriginCache() { - cachedOrigin = null -} - -export interface EnsureSitePublicOriginResult { - origin: string | null - created: boolean -} - -export async function getSitePublicOrigin(db: Database): Promise { - const rows = await db - .select({ value: systemOptions.value }) - .from(systemOptions) - .where(eq(systemOptions.key, SITE_PUBLIC_ORIGIN_KEY)) - .limit(1) - - return normalizePublicOrigin(rows[0]?.value) -} - -export async function ensureSitePublicOrigin(db: Database, requestUrl: string): Promise { - if (cachedOrigin) return { origin: cachedOrigin, created: false } - - const existing = await getSitePublicOrigin(db) - if (existing) { - cachedOrigin = existing - return { origin: existing, created: false } - } - - const origin = originFromRequestUrl(requestUrl) - if (!origin) return { origin: null, created: false } - - // Concurrent first requests may race here; onConflictDoNothing makes the - // insert idempotent and the re-read below settles on the winning value. - await db - .insert(systemOptions) - .values({ key: SITE_PUBLIC_ORIGIN_KEY, value: origin, public: false }) - .onConflictDoNothing({ target: systemOptions.key }) - - const saved = await getSitePublicOrigin(db) - if (saved) cachedOrigin = saved - return { origin: saved, created: saved === 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 - } -} diff --git a/server/services/storage-usage.ts b/server/services/storage-usage.ts deleted file mode 100644 index 049d9e43..00000000 --- a/server/services/storage-usage.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { eq, sql } from 'drizzle-orm' -import { DirType } from '../../shared/constants' -import { imageHostings, matters, orgQuotas, storages } from '../db/schema' -import type { Database } from '../platform/interface' -import { incrementUsageIfEffectiveQuotaAllows } from './effective-quota' - -export class StorageQuotaExceededError extends Error { - constructor() { - super('QUOTA_EXCEEDED') - this.name = 'StorageQuotaExceededError' - } -} - -export interface StorageUsageReservation { - orgId: string - storageId: string - bytes: number -} - -export interface ReserveStorageUsageInput extends StorageUsageReservation { - teamQuotaEnabled?: boolean -} - -type RollbackCleanup = () => Promise | void - -export class StorageUsageMutationContext { - private readonly cleanups: RollbackCleanup[] = [] - - onRollback(cleanup: RollbackCleanup): void { - this.cleanups.push(cleanup) - } - - async rollbackCleanups(): Promise { - for (const cleanup of [...this.cleanups].reverse()) { - await cleanup() - } - } -} - -async function rollbackReservationMutation( - db: Database, - reservations: StorageUsageReservation[], - ctx: StorageUsageMutationContext, - originalError: unknown, -): Promise { - let rollbackError: unknown - - try { - await ctx.rollbackCleanups() - } catch (error) { - rollbackError = error - } - - try { - await rollbackStorageUsageReservations(db, reservations) - } catch (error) { - rollbackError ??= error - } - - if (rollbackError) { - throw rollbackError - } - throw originalError -} - -export async function reserveStorageUsage( - db: Database, - input: ReserveStorageUsageInput, -): Promise { - if (input.bytes <= 0) return null - const allowed = await incrementUsageIfEffectiveQuotaAllows( - db, - input.orgId, - input.storageId, - input.bytes, - input.teamQuotaEnabled ?? true, - ) - if (!allowed) throw new StorageQuotaExceededError() - return { orgId: input.orgId, storageId: input.storageId, bytes: input.bytes } -} - -export async function rollbackStorageUsageReservations( - db: Database, - reservations: Iterable, -): Promise { - const bytesByStorage = new Map() - const bytesByOrg = new Map() - - 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)) - } -} - -export async function withStorageUsageReservation( - db: Database, - inputs: ReserveStorageUsageInput | ReserveStorageUsageInput[], - action: (ctx: StorageUsageMutationContext) => Promise, -): Promise { - const reservations: StorageUsageReservation[] = [] - const ctx = new StorageUsageMutationContext() - - try { - for (const input of Array.isArray(inputs) ? inputs : [inputs]) { - const reservation = await reserveStorageUsage(db, input) - if (reservation) reservations.push(reservation) - } - return await action(ctx) - } catch (error) { - return rollbackReservationMutation(db, reservations, ctx, error) - } -} - -export async function reconcileStorageUsage( - db: Database, - orgId: string, - storageIds: Iterable = [], -): Promise { - 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)) - } -} diff --git a/server/services/storage.ts b/server/services/storage.ts deleted file mode 100644 index 7faa8e48..00000000 --- a/server/services/storage.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { and, asc, count, eq, lt, or } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import type { CreateStorageInput, UpdateStorageInput } from '../../shared/schemas' -import { matters, storages } from '../db/schema' -import type { Database } from '../platform/interface' - -export type Storage = typeof storages.$inferSelect - -export async function listStorages(db: Database): Promise<{ items: Storage[]; total: number }> { - const items = await db.select().from(storages).orderBy(asc(storages.createdAt)) - return { items, total: items.length } -} - -export async function getStorage(db: Database, id: string): Promise { - const rows = await db.select().from(storages).where(eq(storages.id, id)) - return rows[0] ?? null -} - -export async function createStorage(db: Database, input: CreateStorageInput): Promise { - const now = new Date() - const row: Storage = { - 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 row -} - -export async function countStorages(db: Database): Promise { - const rows = await db.select({ count: count() }).from(storages) - return rows[0]?.count ?? 0 -} - -export async function updateStorage(db: Database, id: string, input: UpdateStorageInput): Promise { - const existing = await getStorage(db, 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 { ...existing, ...updated } -} - -export async function deleteStorage(db: Database, id: string): Promise<'ok' | 'not_found' | 'in_use'> { - const existing = await getStorage(db, 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' -} - -export async function selectStorage(db: Database, mode: 'private' | 'public'): Promise { - 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 rows[0] -} diff --git a/server/services/team-count-guard.ts b/server/services/team-count-guard.ts deleted file mode 100644 index 8ddc4e2b..00000000 --- a/server/services/team-count-guard.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { eq } from 'drizzle-orm' -import { FREE_TEAM_LIMIT } from '../../shared/constants' -import { member } from '../db/auth-schema' -import { hasFeature, loadBindingState } from '../licensing/has-feature' -import type { Database } from '../platform/interface' - -export async function countUserOrgs(db: Database, userId: string): Promise { - const rows = await db.select({ id: member.id }).from(member).where(eq(member.userId, userId)) - return rows.length -} - -export async function checkTeamLimit( - db: Database, - userId: string, -): Promise<{ allowed: boolean; count: number; limit: number }> { - const [count, state] = await Promise.all([countUserOrgs(db, userId), loadBindingState(db)]) - const unlimited = hasFeature('teams_unlimited', state) - return { allowed: unlimited || count < FREE_TEAM_LIMIT, count, limit: FREE_TEAM_LIMIT } -} diff --git a/server/services/team-invite.ts b/server/services/team-invite.ts deleted file mode 100644 index e7ba2823..00000000 --- a/server/services/team-invite.ts +++ /dev/null @@ -1,122 +0,0 @@ -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' - -export type TeamInviteLink = typeof teamInviteLinks.$inferSelect - -const generateToken = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 32) - -const DEFAULT_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * 1000 // 7 days - -export async function createInviteLink( - db: Database, - organizationId: string, - inviterId: string, - role: string, - expiresIn?: number, -): Promise { - const token = generateToken() - const now = new Date() - const expiresAt = new Date(now.getTime() + (expiresIn ?? DEFAULT_EXPIRES_IN_MS)) - - const row: TeamInviteLink = { - id: nanoid(), - token, - organizationId, - role, - inviterId, - expiresAt, - createdAt: now, - } - - await db.insert(teamInviteLinks).values(row) - return row -} - -export type InviteLinkInfo = { - organizationId: string - organizationName: string - role: string - expiresAt: Date | null -} - -export async function getInviteLinkInfo(db: Database, token: string): Promise { - 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, - } -} - -export type AcceptResult = 'ok' | 'invalid' | 'expired' | 'already_member' - -export async function acceptInviteLink(db: Database, token: string, userId: string): Promise { - 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' -} - -export type PendingInvitation = { - id: string - email: string - role: string - expiresAt: Date | null - createdAt: Date -} - -export async function listPendingInvitations(db: Database, organizationId: string): Promise { - const rows = await 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)) - - return rows -} diff --git a/server/services/team.ts b/server/services/team.ts deleted file mode 100644 index 9183b301..00000000 --- a/server/services/team.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 { getEffectiveQuota, getEffectiveQuotasByOrg } from './effective-quota' - -export interface TeamSummary { - id: string - name: string - slug: string - logo: string | null - memberCount: number - ownerName: string | null - quotaUsed: number - quotaTotal: number - createdAt: number -} - -// 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-%')) - -export async function listTeams(db: Database): Promise { - 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 getEffectiveQuotasByOrg(db, 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 quota = 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: quota?.used ?? 0, - quotaTotal: quota?.quota ?? 0, - createdAt: r.createdAt.getTime(), - } - }) -} - -export async function getTeam(db: Database, orgId: string): Promise { - 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 quota = await getEffectiveQuota(db, 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: quota.used, - quotaTotal: quota.quota, - createdAt: org.createdAt.getTime(), - } -} - -// First owner per org (by member creation order), for a display label. -async function listOwnerNames(db: Database, orgIds: string[]): Promise> { - // 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() - for (const r of chunks.flat()) { - if (!byOrg.has(r.orgId)) byOrg.set(r.orgId, r.name || r.email) - } - return byOrg -} - -function chunk(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 -} diff --git a/server/services/webdav-path.ts b/server/services/webdav-path.ts deleted file mode 100644 index f6ac918e..00000000 --- a/server/services/webdav-path.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { and, asc, desc, eq } from 'drizzle-orm' -import { DirType, ObjectStatus } from '../../shared/constants' -import { member, organization } from '../db/auth-schema' -import { matters } from '../db/schema' -import type { Database } from '../platform/interface' -import type { Matter } from './matter' - -export interface WebDavWorkspace { - id: string - name: string - slug: string - href: string -} - -export interface WebDavTarget { - workspace: WebDavWorkspace | null - mountRoot: boolean - parent: string - name: string - matter: Matter | null -} - -export class WebDavPathError extends Error { - constructor( - message: string, - public status: number, - ) { - super(message) - } -} - -export function joinMatterPath(parent: string, name: string): string { - return parent ? `${parent}/${name}` : name -} - -export function matterHref(workspace: WebDavWorkspace, matter: Matter): 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 async function listUserWorkspaces(db: Database, userId: string): Promise { - 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)}/` })) -} - -export async function getUserWorkspace( - db: Database, - userId: string, - slugOrId: string, -): Promise { - 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 -} - -export async function listChildren(db: Database, orgId: string, parent: string): Promise { - 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)) -} - -export async function resolveWebDavPath(db: Database, userId: string, rawPath: string): Promise { - const parts = decodeDavPath(rawPath) - if (parts.length === 0) return { workspace: null, mountRoot: true, parent: '', name: '', matter: null } - - const workspace = await getUserWorkspace(db, 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 } -} - -export async function resolveExistingWebDavPath(db: Database, userId: string, rawPath: string): Promise { - const target = await resolveWebDavPath(db, 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 { - 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 -} - -export function ensureFolder(target: WebDavTarget): Matter { - if (!target.matter) throw new WebDavPathError('Parent collection not found', 409) - if (target.matter.dirtype === DirType.FILE) throw new WebDavPathError('Not a collection', 405) - return target.matter -} diff --git a/server/services/webdav-state.cf-test.ts b/server/services/webdav-state.cf-test.ts deleted file mode 100644 index f590a032..00000000 --- a/server/services/webdav-state.cf-test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { env } from 'cloudflare:workers' -import { nanoid } from 'nanoid' -import { describe, expect, it } from 'vitest' -import { createCloudflarePlatform } from '../platform/cloudflare' -import { activeLocks, conflictingLocks, createLock, refreshLock, removeLock } from './webdav-state' - -function buildDb() { - return createCloudflarePlatform(env).db -} - -describe('[CF] WebDAV locks on D1', () => { - it('matches depth-infinity lock scopes without D1 dynamic LIKE expressions', async () => { - const db = buildDb() - const orgId = `org-${nanoid(8)}` - const parent = await createLock(db, { - orgId, - resourcePath: 'Folder', - owner: 'tester', - depth: 'infinity', - timeoutSeconds: 3600, - }) - await createLock(db, { - orgId, - resourcePath: 'Other', - owner: 'tester', - depth: '0', - timeoutSeconds: 3600, - }) - - expect((await activeLocks(db, orgId, 'Folder/child.txt')).map((lock) => lock.id)).toEqual([parent.id]) - expect((await conflictingLocks(db, orgId, 'Folder')).map((lock) => lock.id)).toEqual([parent.id]) - expect(await refreshLock(db, orgId, 'Folder/child.txt', parent.token, 3600)).toMatchObject({ id: parent.id }) - expect(await removeLock(db, orgId, 'Folder/child.txt', parent.token)).toBe(true) - expect(await activeLocks(db, orgId, 'Folder/child.txt')).toEqual([]) - }) -}) diff --git a/server/services/webdav-state.ts b/server/services/webdav-state.ts deleted file mode 100644 index e14e04f3..00000000 --- a/server/services/webdav-state.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { and, eq, inArray, or, sql } from 'drizzle-orm' -import { nanoid } from 'nanoid' -import { webdavDeadProperties, webdavLocks } from '../db/schema' -import type { Database } from '../platform/interface' -import { type AtomicQuery, executeWriteTransaction } from './db-transaction' - -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 async function listDeadProperties( - db: Database, - orgId: string, - resourcePath: string, -): Promise { - const rows = await db - .select({ - namespace: webdavDeadProperties.namespace, - name: webdavDeadProperties.name, - value: webdavDeadProperties.value, - }) - .from(webdavDeadProperties) - .where(and(eq(webdavDeadProperties.orgId, orgId), eq(webdavDeadProperties.resourcePath, resourcePath))) - return rows -} - -export async function listDeadPropertiesForResources( - db: Database, - orgId: string, - resourcePaths: string[], -): Promise> { - 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 -} - -export async function applyDeadPropertyUpdate( - db: Database, - orgId: string, - resourcePath: string, - operations: Array<{ action: 'set'; property: DavDeadProperty } | { action: 'remove'; property: DavPropertyName }>, -): Promise { - 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) -} - -export async function deleteWebDavState(db: Database, orgId: string, resourcePath: string): Promise { - 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}/%`}`), - ), - ), - ]) -} - -export async function moveWebDavState(db: Database, orgId: string, oldPath: string, newPath: string): Promise { - 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}/%`}`), - ), - ), - ]) -} - -export async function copyDeadProperties( - db: Database, - orgId: string, - sourcePath: string, - targetPath: string, -): Promise { - 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 }, - }), - ), - ) -} - -export async function activeLocks(db: Database, orgId: string, resourcePath: string): Promise { - 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)) -} - -export async function activeLocksForResources( - db: Database, - orgId: string, - resourcePaths: string[], -): Promise> { - 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 -} - -export async function conflictingLocks(db: Database, orgId: string, resourcePath: string): Promise { - 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)) -} - -export async function directLocks(db: Database, orgId: string, resourcePath: string): Promise { - await purgeExpiredLocks(db) - const now = Date.now() - return db - .select() - .from(webdavLocks) - .where( - and( - eq(webdavLocks.orgId, orgId), - eq(webdavLocks.resourcePath, resourcePath), - sql`${webdavLocks.expiresAt} > ${now}`, - ), - ) -} - -export async function createLock( - db: Database, - input: { orgId: string; resourcePath: string; owner: string; depth: string; timeoutSeconds: number }, -): Promise { - 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 -} - -export async function refreshLock( - db: Database, - orgId: string, - resourcePath: string, - token: string, - timeoutSeconds: number, -): Promise { - 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 } -} - -export async function removeLock(db: Database, orgId: string, resourcePath: string, token: string): Promise { - 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 { - 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}/`) -} diff --git a/server/test/setup.ts b/server/test/setup.ts index 212e627b..4f71e4f1 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -5,10 +5,11 @@ import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import type { LicenseEdition } from '../../shared/types' import { createApp } from '../app' import { createAuth } from '../auth' +import { createDeps } from '../composition' import * as authSchema from '../db/auth-schema' import * as schema from '../db/schema' import type { Platform } from '../platform/interface' -import { resetSitePublicOriginCache } from '../services/site-public-origin' +import { resetSitePublicOriginCache } from '../usecases/site-public-origin' const AUTH_SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS user ( @@ -570,9 +571,10 @@ export async function createTestApp( getBinding: (key: string) => bindingOverrides[key] as T | undefined, } const auth = await createAuth(platform, 'test-secret', 'http://localhost:3000') - const app = createApp(platform, auth) + const deps = createDeps(platform) + const app = createApp(platform, auth, deps) - return { app, db, auth, platform } + return { app, db, auth, platform, deps } } export async function adminHeaders(app: ReturnType) { @@ -629,12 +631,12 @@ async function seedLicense( edition: LicenseEdition }, ) { - const { PUBLIC_KEYS } = await import('../licensing/public-keys.js') + const { PUBLIC_KEYS } = await import('../domain/license-keys.js') if (!PUBLIC_KEYS.includes(TEST_LICENSE_PUBLIC)) { PUBLIC_KEYS.unshift(TEST_LICENSE_PUBLIC) } - const { createLicenseBinding } = await import('../licensing/license-state.js') + const { createLicenseBindingRepo } = await import('../adapters/repos/license-binding.js') const issuedAt = nowSec() const expiresAt = issuedAt + 3600 const cachedCert = sign(TEST_LICENSE_SECRET, { @@ -653,7 +655,7 @@ async function seedLicense( expiresAt, }) - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'test-binding', cloudStoreId: 'store-test-binding', instanceId: 'test-instance', diff --git a/server/services/archive-processing.test.ts b/server/usecases/archive-processing.test.ts similarity index 85% rename from server/services/archive-processing.test.ts rename to server/usecases/archive-processing.test.ts index 024159ba..3ef90691 100644 --- a/server/services/archive-processing.test.ts +++ b/server/usecases/archive-processing.test.ts @@ -1,15 +1,46 @@ import { sql } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import type { BackgroundJob } from '../../shared/types' +import { createZipGateway } from '../adapters/gateways/zip' +import { createArchiveTargetFolderRepo } from '../adapters/repos/archive-target-folder' +import { createBackgroundJobRepo } from '../adapters/repos/background-job' +import { createMatterRepo } from '../adapters/repos/matter' +import { createNotificationRepo } from '../adapters/repos/notification' +import { createQuotaRepo } from '../adapters/repos/quota' +import { createStorageRepo } from '../adapters/repos/storage' +import { createStorageUsageRepo } from '../adapters/repos/storage-usage' +import { createZipPlanRepo } from '../adapters/repos/zip' import { createTestApp } from '../test/setup.js' -import { createArchiveJob, enqueueArchiveJob, processArchiveJob } from './archive-processing' -import { getBackgroundJob } from './background-jobs' -import type { S3Service } from './s3' -import { collectCompressionPlan, createZipArchiveStream, ZIP_COMPRESS_LIMITS } from './zip-compress' -import { validateAndExtractZip, ZIP_EXTRACT_LIMITS } from './zip-extract' +import { + type ArchiveProcessingDeps, + createArchiveJob, + enqueueArchiveJob, + processArchiveJob, +} from './archive-processing' +import { type S3Gateway, ZIP_COMPRESS_LIMITS, ZIP_EXTRACT_LIMITS } from './ports' + +const zip = createZipGateway() type TestDb = Awaited>['db'] +// Assembles the same port subset the queue-consumer gateway wires, so the usecase +// runs against real repos over the in-memory DB while each test still injects its +// own S3 fake via `input.s3`. +function archiveDeps(db: TestDb): ArchiveProcessingDeps { + return { + s3: undefined as unknown as S3Gateway, + 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), + } +} + const ORG_ID = 'archive-org' const USER_ID = 'archive-user' const STORAGE_ID = 'archive-storage' @@ -188,11 +219,11 @@ describe('archive processing', () => { const s3 = new MemoryS3() s3.objects.set('source/archive.zip', createZip({ 'docs/hello.txt': bytes('hello') })) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'zip-matter' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'completed', type: 'archive_extract' }) @@ -216,17 +247,17 @@ describe('archive processing', () => { await seedStorage(db) const size = 128 * 1024 * 1024 const archive = await streamToBytes( - createZipArchiveStream([{ archivePath: 'large.bin', openStream: async () => generatedBytes(size) }]), + zip.createZipArchiveStream([{ archivePath: 'large.bin', openStream: async () => generatedBytes(size) }]), ) await seedMatter(db, { id: 'large-zip', name: 'large.zip', object: 'source/large.zip', size: archive.byteLength }) const s3 = new GeneratedObjectS3('unused', 0) s3.objects.set('source/large.zip', archive) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'large-zip' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'completed', type: 'archive_extract' }) @@ -243,11 +274,11 @@ describe('archive processing', () => { const s3 = new MemoryS3() s3.objects.set('objects/a.txt', bytes('hello')) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_compress', matterIds: ['file-a'] }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'completed', type: 'archive_compress' }) @@ -268,11 +299,11 @@ describe('archive processing', () => { await seedMatter(db, { id: 'large-file', name: 'large.bin', object: 'objects/large.bin', size }) const s3 = new GeneratedObjectS3('objects/large.bin', size) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_compress', matterIds: ['large-file'] }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'completed', type: 'archive_compress' }) @@ -289,18 +320,18 @@ describe('archive processing', () => { const s3 = new BlockingGeneratedObjectS3('objects/progress.bin', size, 6 * 1024 * 1024) const request = { type: 'archive_compress' as const, matterIds: ['progress-file'] } - const queued = await enqueueArchiveJob(db, { + const queued = await enqueueArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) - const processing = processArchiveJob(db, { + const processing = processArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request, jobId: queued.id, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) try { @@ -332,18 +363,18 @@ describe('archive processing', () => { const s3 = new BlockingStoredObjectS3(6 * 1024 * 1024) s3.objects.set('source/progress.zip', archive) const request = { type: 'archive_extract' as const, matterId: 'progress-zip' } - const queued = await enqueueArchiveJob(db, { + const queued = await enqueueArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) - const processing = processArchiveJob(db, { + const processing = processArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request, jobId: queued.id, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) try { @@ -371,11 +402,11 @@ describe('archive processing', () => { const s3 = new MemoryS3() - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_compress', matterIds: ['empty-folder'] }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'completed', type: 'archive_compress' }) @@ -383,7 +414,7 @@ describe('archive processing', () => { SELECT object FROM matters WHERE org_id = ${ORG_ID} AND name = 'Empty.zip' AND status = 'active' `) - const archive = validateAndExtractZip(s3.objects.get(zipMatter[0].object)!) + const archive = zip.validateAndExtractZip(s3.objects.get(zipMatter[0].object)!) expect(archive.folders).toEqual(['Empty']) expect(archive.files).toEqual([]) }) @@ -396,11 +427,11 @@ describe('archive processing', () => { const s3 = new MemoryS3() - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_compress', matterIds: ['parent-folder'] }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'completed', type: 'archive_compress' }) @@ -408,7 +439,7 @@ describe('archive processing', () => { SELECT object FROM matters WHERE org_id = ${ORG_ID} AND name = 'Parent.zip' AND status = 'active' `) - const archive = validateAndExtractZip(s3.objects.get(zipMatter[0].object)!) + const archive = zip.validateAndExtractZip(s3.objects.get(zipMatter[0].object)!) expect(archive.folders).toEqual(['Parent', 'Parent/Child']) expect(archive.files).toEqual([]) }) @@ -421,11 +452,11 @@ describe('archive processing', () => { const s3 = new MemoryS3() s3.objects.set('source/bad.zip', createZip({ '../evil.txt': bytes('no') })) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'bad-zip' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job.status).toBe('failed') @@ -448,11 +479,11 @@ describe('archive processing', () => { ), ) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'large-zip' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ @@ -475,11 +506,11 @@ describe('archive processing', () => { const s3 = new MemoryS3() s3.objects.set('source/quota.zip', createZip({ 'hello.txt': bytes('hello') })) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'quota-zip' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job.status).toBe('failed') @@ -501,11 +532,11 @@ describe('archive processing', () => { const s3 = new MemoryS3() s3.objects.set('objects/a.txt', bytes('hello')) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_compress', matterIds: ['file-a'] }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job.status).toBe('failed') @@ -522,17 +553,17 @@ describe('archive processing', () => { const s3 = new MemoryS3() - const missingTarget = await createArchiveJob(db, { + const missingTarget = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_compress', matterIds: ['file-a'], targetFolder: 'missing' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) - const fileTarget = await createArchiveJob(db, { + const fileTarget = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_compress', matterIds: ['file-a'], targetFolder: 'target.txt' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(missingTarget).toMatchObject({ status: 'failed', errorMessage: 'Target folder not found' }) @@ -547,17 +578,17 @@ describe('archive processing', () => { const s3 = new MemoryS3() - const missingJob = await createArchiveJob(db, { + const missingJob = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'missing-zip' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) - const plainJob = await createArchiveJob(db, { + const plainJob = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'plain-file' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(missingJob).toMatchObject({ status: 'failed', errorMessage: 'ZIP matter not found' }) @@ -572,17 +603,17 @@ describe('archive processing', () => { const s3 = new MemoryS3() - const missingTarget = await createArchiveJob(db, { + const missingTarget = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'zip-matter', targetFolder: 'missing' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) - const fileTarget = await createArchiveJob(db, { + const fileTarget = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'zip-matter', targetFolder: 'target.txt' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(missingTarget).toMatchObject({ status: 'failed', errorMessage: 'Target folder not found' }) @@ -594,11 +625,11 @@ describe('archive processing', () => { const { db } = await createTestApp() await seedMatter(db, { id: 'zip-matter', name: 'archive.zip', object: 'source/archive.zip', size: 200 }) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'zip-matter' }, - s3: new MemoryS3() as unknown as S3Service, + s3: new MemoryS3() as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'failed', errorMessage: 'Storage not found' }) @@ -612,11 +643,11 @@ describe('archive processing', () => { const s3 = new FailingPutS3() s3.objects.set('source/archive.zip', createZip({ 'hello.txt': bytes('hello') })) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'zip-matter' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'failed', errorMessage: 'S3 put failed' }) @@ -633,11 +664,11 @@ describe('archive processing', () => { const s3 = new FailingPutS3() s3.objects.set('source/archive.zip', createZip({ 'docs/hello.txt': bytes('hello') })) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'zip-matter' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'failed', errorMessage: 'S3 put failed' }) @@ -660,11 +691,11 @@ describe('archive processing', () => { }), ) - const job = await createArchiveJob(db, { + const job = await createArchiveJob(archiveDeps(db), { orgId: ORG_ID, userId: USER_ID, request: { type: 'archive_extract', matterId: 'zip-matter' }, - s3: s3 as unknown as S3Service, + s3: s3 as unknown as S3Gateway, }) expect(job).toMatchObject({ status: 'failed', errorMessage: 'S3 put failed' }) @@ -701,19 +732,20 @@ describe('archive processing', () => { await seedMatter(db, { id: 'same-a', name: 'same.txt', parent: 'a', object: 'objects/same-a.txt', size: 1 }) await seedMatter(db, { id: 'same-b', name: 'same.txt', parent: 'b', object: 'objects/same-b.txt', size: 1 }) - await expect(collectCompressionPlan(db, ORG_ID, ['missing'])).rejects.toThrow( + const zipPlan = createZipPlanRepo(db) + await expect(zipPlan.collectCompressionPlan(ORG_ID, ['missing'])).rejects.toThrow( 'Some archive source IDs do not belong to this organization', ) - await expect(collectCompressionPlan(db, ORG_ID, ['inactive-file'])).rejects.toThrow( + await expect(zipPlan.collectCompressionPlan(ORG_ID, ['inactive-file'])).rejects.toThrow( 'Only active matters can be archived', ) - await expect(collectCompressionPlan(db, ORG_ID, ['large-file'])).rejects.toThrow( + await expect(zipPlan.collectCompressionPlan(ORG_ID, ['large-file'])).rejects.toThrow( `Compression source file exceeds ${ZIP_COMPRESS_LIMITS.singleFileBytes} bytes`, ) - await expect(collectCompressionPlan(db, ORG_ID, ['deep-file'])).rejects.toThrow( + await expect(zipPlan.collectCompressionPlan(ORG_ID, ['deep-file'])).rejects.toThrow( 'Compression directory depth exceeds 10', ) - await expect(collectCompressionPlan(db, ORG_ID, ['same-a', 'same-b'])).rejects.toThrow( + await expect(zipPlan.collectCompressionPlan(ORG_ID, ['same-a', 'same-b'])).rejects.toThrow( 'Duplicate archive path: same.txt', ) }) @@ -724,7 +756,7 @@ describe('archive processing', () => { await seedMatter(db, { id: 'photos', name: 'Photos', object: '', size: 0, dirtype: 1 }) await seedMatter(db, { id: 'photo-a', name: 'a.jpg', parent: 'Photos', object: 'objects/a.jpg', size: 1 }) - const plan = await collectCompressionPlan(db, ORG_ID, ['photos'], { outputName: 'backup' }) + const plan = await createZipPlanRepo(db).collectCompressionPlan(ORG_ID, ['photos'], { outputName: 'backup' }) expect(plan).toMatchObject({ outputName: 'backup.zip', targetFolder: '' }) expect(plan.directories.map((directory) => directory.archivePath)).toEqual(['Photos']) @@ -741,32 +773,36 @@ describe('archive processing', () => { await seedMatter(db, { id, name: `${id}.txt`, object: `objects/${id}.txt`, size: 1 }) } - await expect(collectCompressionPlan(db, ORG_ID, ids)).rejects.toThrow( + await expect(createZipPlanRepo(db).collectCompressionPlan(ORG_ID, ids)).rejects.toThrow( `Compression file count exceeds ${ZIP_COMPRESS_LIMITS.fileCount}`, ) }) it('rejects unsafe and unsupported ZIP entries during validation', () => { - expect(() => validateAndExtractZip(new Uint8Array())).toThrow('Invalid ZIP archive') - expect(() => validateAndExtractZip(createZip({ '/abs.txt': bytes('x') }))).toThrow('ZIP contains an absolute path') - expect(() => validateAndExtractZip(createZip({ 'a\\b.txt': bytes('x') }))).toThrow( + expect(() => zip.validateAndExtractZip(new Uint8Array())).toThrow('Invalid ZIP archive') + expect(() => zip.validateAndExtractZip(createZip({ '/abs.txt': bytes('x') }))).toThrow( + 'ZIP contains an absolute path', + ) + expect(() => zip.validateAndExtractZip(createZip({ 'a\\b.txt': bytes('x') }))).toThrow( 'ZIP paths must use forward slashes', ) - expect(() => validateAndExtractZip(createZip({ 'a//b.txt': bytes('x') }))).toThrow( + expect(() => zip.validateAndExtractZip(createZip({ 'a//b.txt': bytes('x') }))).toThrow( 'ZIP contains an empty path segment', ) expect(() => - validateAndExtractZip(createZip({ 'secret.txt': bytes('x') }, { flags: { 'secret.txt': 1 } })), + zip.validateAndExtractZip(createZip({ 'secret.txt': bytes('x') }, { flags: { 'secret.txt': 1 } })), ).toThrow('Encrypted ZIP archives are not supported') expect(() => - validateAndExtractZip(createZip({ 'unsupported.txt': bytes('x') }, { compression: { 'unsupported.txt': 14 } })), + zip.validateAndExtractZip( + createZip({ 'unsupported.txt': bytes('x') }, { compression: { 'unsupported.txt': 14 } }), + ), ).toThrow('ZIP contains unsupported compression method') expect(() => - validateAndExtractZip( + zip.validateAndExtractZip( createZip({ 'link.txt': bytes('x') }, { externalAttributes: { 'link.txt': 0o120000 << 16 } }), ), ).toThrow('ZIP contains unsupported entry type') - expect(() => validateAndExtractZip(createZip({ 'a/b/c/d/e/f/g/h/i/j/k/file.txt': bytes('x') }))).toThrow( + expect(() => zip.validateAndExtractZip(createZip({ 'a/b/c/d/e/f/g/h/i/j/k/file.txt': bytes('x') }))).toThrow( 'ZIP directory depth exceeds 10', ) }) @@ -775,7 +811,7 @@ describe('archive processing', () => { const manyEntries = Object.fromEntries( Array.from({ length: ZIP_EXTRACT_LIMITS.fileCount + 1 }, (_, index) => [`file-${index}.txt`, bytes('x')]), ) - expect(() => validateAndExtractZip(createZip(manyEntries))).toThrow( + expect(() => zip.validateAndExtractZip(createZip(manyEntries))).toThrow( `ZIP file count exceeds ${ZIP_EXTRACT_LIMITS.fileCount}`, ) const totalLimitEntries = Object.fromEntries( @@ -784,7 +820,7 @@ describe('archive processing', () => { const totalLimitSizes = Object.fromEntries( Array.from({ length: 5 }, (_, index) => [`total-${index}`, 256 * 1024 * 1024]), ) - expect(() => validateAndExtractZip(createZip(totalLimitEntries, { declaredSizes: totalLimitSizes }))).toThrow( + expect(() => zip.validateAndExtractZip(createZip(totalLimitEntries, { declaredSizes: totalLimitSizes }))).toThrow( `ZIP extraction output exceeds ${ZIP_EXTRACT_LIMITS.totalOutputBytes} bytes`, ) }) @@ -832,7 +868,7 @@ async function waitForJobProgress( ): Promise { const deadline = Date.now() + 3000 for (;;) { - const job = await getBackgroundJob(db, ORG_ID, jobId) + const job = await createBackgroundJobRepo(db).get(ORG_ID, jobId) if (predicate(job)) return job if (Date.now() >= deadline) throw new Error('Timed out waiting for archive job progress') await sleep(20) diff --git a/server/services/archive-processing.ts b/server/usecases/archive-processing.ts similarity index 68% rename from server/services/archive-processing.ts rename to server/usecases/archive-processing.ts index 3fb8df76..1ef87768 100644 --- a/server/services/archive-processing.ts +++ b/server/usecases/archive-processing.ts @@ -1,24 +1,44 @@ +import { DirType } from '@shared/constants' import type { CreateBackgroundJobRequest } from '@shared/schemas' import type { BackgroundJob } from '@shared/types' -import { and, eq } from 'drizzle-orm' -import { DirType } from '../../shared/constants' -import { matters } from '../db/schema' -import type { Database } from '../platform/interface' -import { createBackgroundJob, updateBackgroundJob } from './background-jobs' -import { createMatter, getMatter, purgeMatters } from './matter' -import { createNotification } from './notification' -import { buildObjectKey } from './path-template' -import { S3Service } from './s3' -import { getStorage, type Storage as S3StorageType, selectStorage } from './storage' +import { buildObjectKey } from '../lib/path-template' +import type { + ArchiveTargetFolderRepo, + BackgroundJobRepo, + MatterRepo, + NotificationRepo, + QuotaRepo, + S3Gateway, + StorageRecord, + StorageRepo, + StorageUsageRepo, + ZipGateway, + ZipPlanRepo, +} from './ports' import { StorageQuotaExceededError, withStorageUsageReservation } from './storage-usage' -import { collectCompressionPlan, createZipArchiveStream } from './zip-compress' -import { streamValidatedZip, validateZipDirectory } from './zip-extract' + +// Existing ports the archive orchestration composes. No new persistence beyond +// the target-folder lookup; everything else is reused (matter repo, zip +// gateway/plan, s3, quota/usage reservation, background-job + notification repos). +export type ArchiveProcessingDeps = { + s3: S3Gateway + storages: StorageRepo + quota: QuotaRepo + storageUsage: StorageUsageRepo + backgroundJobs: BackgroundJobRepo + notifications: NotificationRepo + zip: ZipGateway + zipPlan: ZipPlanRepo + archiveTargetFolders: ArchiveTargetFolderRepo + matter: MatterRepo +} export interface CreateArchiveJobInput { orgId: string userId: string request: CreateBackgroundJobRequest - s3?: S3Service + // Overrides deps.s3 for tests; production paths use the wired gateway. + s3?: S3Gateway } const ZIP_MIME = 'application/zip' @@ -26,14 +46,20 @@ const DEFAULT_FILE_MIME = 'application/octet-stream' const PROGRESS_REPORT_INTERVAL_MS = 1000 const PROGRESS_REPORT_BYTES = 5 * 1024 * 1024 -export async function createArchiveJob(db: Database, input: CreateArchiveJobInput): Promise { - const job = await enqueueArchiveJob(db, input) - return processArchiveJob(db, { ...input, jobId: job.id }) +export async function createArchiveJob( + deps: ArchiveProcessingDeps, + input: CreateArchiveJobInput, +): Promise { + const job = await enqueueArchiveJob(deps, input) + return processArchiveJob(deps, { ...input, jobId: job.id }) } -export async function enqueueArchiveJob(db: Database, input: CreateArchiveJobInput): Promise { +export async function enqueueArchiveJob( + deps: ArchiveProcessingDeps, + input: CreateArchiveJobInput, +): Promise { const targetFolder = input.request.targetFolder ?? null - return createBackgroundJob(db, { + return deps.backgroundJobs.create({ orgId: input.orgId, userId: input.userId, type: input.request.type, @@ -44,49 +70,50 @@ export async function enqueueArchiveJob(db: Database, input: CreateArchiveJobInp } export async function processArchiveJob( - db: Database, + deps: ArchiveProcessingDeps, input: CreateArchiveJobInput & { jobId: string }, ): Promise { - const s3 = input.s3 ?? new S3Service() + const s3 = input.s3 ?? deps.s3 try { - await updateBackgroundJob(db, input.orgId, input.jobId, { status: 'running', startedAt: new Date() }) + await deps.backgroundJobs.update(input.orgId, input.jobId, { status: 'running', startedAt: new Date() }) const finished = input.request.type === 'archive_compress' - ? await runCompressionJob(db, s3, input.jobId, input.orgId, input.userId, input.request) - : await runExtractionJob(db, s3, input.jobId, input.orgId, input.userId, input.request) - await notifyArchiveJobFinished(db, finished) + ? await runCompressionJob(deps, s3, input.jobId, input.orgId, input.userId, input.request) + : await runExtractionJob(deps, s3, input.jobId, input.orgId, input.userId, input.request) + await notifyArchiveJobFinished(deps, finished) return finished } catch (error) { - const failed = await updateBackgroundJob(db, input.orgId, input.jobId, { + const failed = await deps.backgroundJobs.update(input.orgId, input.jobId, { status: 'failed', errorMessage: (error as Error).message, retryable: false, cancelable: false, }) - await notifyArchiveJobFinished(db, failed) + await notifyArchiveJobFinished(deps, failed) return failed } } async function runCompressionJob( - db: Database, - s3: S3Service, + deps: ArchiveProcessingDeps, + s3: S3Gateway, jobId: string, orgId: string, userId: string, request: Extract, ): Promise { - if (request.targetFolder !== undefined) await requireTargetFolder(db, orgId, request.targetFolder) - const plan = await collectCompressionPlan(db, orgId, request.matterIds, { + if (request.targetFolder !== undefined) + await deps.archiveTargetFolders.requireTargetFolder(orgId, request.targetFolder) + const plan = await deps.zipPlan.collectCompressionPlan(orgId, request.matterIds, { targetFolder: request.targetFolder, outputName: request.outputName, }) - const progress = createArchiveProgressReporter(db, orgId, jobId, plan.inputBytes, plan.files.length) + const progress = createArchiveProgressReporter(deps, orgId, jobId, plan.inputBytes, plan.files.length) await progress.report(true) const sources = [] for (const file of plan.files) { - const storage = await requireStorage(db, file.matter.storageId) + const storage = await requireStorage(deps, file.matter.storageId) sources.push({ archivePath: file.archivePath, openStream: async () => { @@ -98,19 +125,24 @@ async function runCompressionJob( }) } - const targetStorage = await selectStorage(db, 'private') + const targetStorage = await deps.storages.select('private') const key = buildObjectKey({ uid: userId, orgId, rawExt: '.zip' }) let objectWritten = false let outputBytes = 0 try { - outputBytes = await s3.putObject(targetStorage, key, createZipArchiveStream(sources, plan.directories), ZIP_MIME) + outputBytes = await s3.putObject( + targetStorage, + key, + deps.zip.createZipArchiveStream(sources, plan.directories), + ZIP_MIME, + ) objectWritten = true const job = await withStorageUsageReservation( - db, + { quota: deps.quota, storageUsage: deps.storageUsage }, { orgId, storageId: targetStorage.id, bytes: outputBytes }, async (ctx) => { ctx.onRollback(() => s3.deleteObject(targetStorage, key)) - const matter = await createMatter(db, { + const matter = await deps.matter.create({ orgId, userId, name: plan.outputName, @@ -124,7 +156,7 @@ async function runCompressionJob( onConflict: 'rename', }) - return updateBackgroundJob(db, orgId, jobId, { + return deps.backgroundJobs.update(orgId, jobId, { status: 'completed', progress: { inputBytes: plan.inputBytes, @@ -148,39 +180,41 @@ async function runCompressionJob( } async function runExtractionJob( - db: Database, - s3: S3Service, + deps: ArchiveProcessingDeps, + s3: S3Gateway, jobId: string, orgId: string, userId: string, request: Extract, ): Promise { - const zipMatter = await getMatter(db, request.matterId, orgId) + const zipMatter = await deps.matter.get(request.matterId, orgId) if (!zipMatter || zipMatter.status !== 'active') throw new Error('ZIP matter not found') if (zipMatter.dirtype !== DirType.FILE || !zipMatter.name.toLowerCase().endsWith('.zip')) { throw new Error('Extraction source must be a .zip file') } - if (request.targetFolder !== undefined) await requireTargetFolder(db, orgId, request.targetFolder) - const sourceStorage = await requireStorage(db, zipMatter.storageId) + if (request.targetFolder !== undefined) + await deps.archiveTargetFolders.requireTargetFolder(orgId, request.targetFolder) + const sourceStorage = await requireStorage(deps, zipMatter.storageId) + const zip = deps.zip const sourceHead = await s3.headObject(sourceStorage, zipMatter.object) - const plan = await validateZipDirectory(sourceHead.size, (start, end) => + const plan = await zip.validateZipDirectory(sourceHead.size, (start, end) => s3.getObjectBytes(sourceStorage, zipMatter.object, `bytes=${start}-${end}`), ) - const progress = createArchiveProgressReporter(db, orgId, jobId, sourceHead.size, plan.fileCount) + const progress = createArchiveProgressReporter(deps, orgId, jobId, sourceHead.size, plan.fileCount) await progress.report(true) const targetFolder = request.targetFolder ?? zipMatter.parent - const targetStorage = await selectStorage(db, 'private') + const targetStorage = await deps.storages.select('private') const writtenKeys: string[] = [] const createdMatterIds: string[] = [] const folderParents = new Map() try { return await withStorageUsageReservation( - db, + { quota: deps.quota, storageUsage: deps.storageUsage }, { orgId, storageId: targetStorage.id, bytes: plan.totalBytes }, async (ctx) => { ctx.onRollback(async () => { - await purgeMatters(db, orgId, createdMatterIds) + await deps.matter.purge(orgId, createdMatterIds) await s3.deleteObjects(targetStorage, writtenKeys) }) @@ -191,14 +225,14 @@ async function runExtractionJob( const zipStream = trackReadableStream(await s3.getObjectStream(sourceStorage, zipMatter.object), (chunk) => progress.addProcessedBytes(chunk.byteLength), ) - const archive = await streamValidatedZip(zipStream, async (file) => { + const archive = await zip.streamValidatedZip(zipStream, async (file) => { await progress.setCurrentFilename(file.path) const parent = file.parentPath ? await ensureExtractedFolder(file.parentPath) : targetFolder const key = buildObjectKey({ uid: userId, orgId, rawExt: extension(file.name) }) const size = await s3.putObject(targetStorage, key, file.stream, DEFAULT_FILE_MIME) await file.size writtenKeys.push(key) - const matter = await createMatter(db, { + const matter = await deps.matter.create({ orgId, userId, name: file.name, @@ -214,7 +248,7 @@ async function runExtractionJob( createdMatterIds.push(matter.id) }) - return updateBackgroundJob(db, orgId, jobId, { + return deps.backgroundJobs.update(orgId, jobId, { status: 'completed', progress: { inputBytes: sourceHead.size, @@ -240,7 +274,7 @@ async function runExtractionJob( const parts = folderPath.split('/') const parentPath = parts.slice(0, -1).join('/') const parent = parentPath ? await ensureExtractedFolder(parentPath) : targetFolder - const folder = await createMatter(db, { + const folder = await deps.matter.create({ orgId, userId, name: parts[parts.length - 1], @@ -261,7 +295,7 @@ async function runExtractionJob( } function createArchiveProgressReporter( - db: Database, + deps: ArchiveProcessingDeps, orgId: string, jobId: string, inputBytes: number, @@ -287,14 +321,16 @@ function createArchiveProgressReporter( lastReportedBytes = snapshot.processedBytes lastReportedFilename = snapshot.currentFilename writes = writes.then(() => - updateBackgroundJob(db, orgId, jobId, { - progress: { - inputBytes, - fileCount, - processedBytes: snapshot.processedBytes, - currentFilename: snapshot.currentFilename, - }, - }).then(() => undefined), + deps.backgroundJobs + .update(orgId, jobId, { + progress: { + inputBytes, + fileCount, + processedBytes: snapshot.processedBytes, + currentFilename: snapshot.currentFilename, + }, + }) + .then(() => undefined), ) await writes } @@ -333,30 +369,12 @@ function trackReadableStream( }) } -async function requireStorage(db: Database, storageId: string): Promise { - const storage = await getStorage(db, storageId) +async function requireStorage(deps: ArchiveProcessingDeps, storageId: string): Promise { + const storage = await deps.storages.get(storageId) if (!storage) throw new Error('Storage not found') return storage } -async function requireTargetFolder(db: Database, orgId: string, targetFolder: string): Promise { - 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') -} - function buildMatterPath(parent: string, name: string): string { return parent ? `${parent}/${name}` : name } @@ -366,10 +384,10 @@ function extension(name: string): string { return dot >= 0 ? name.slice(dot) : '' } -async function notifyArchiveJobFinished(db: Database, job: BackgroundJob): Promise { +async function notifyArchiveJobFinished(deps: ArchiveProcessingDeps, job: BackgroundJob): Promise { const completed = job.status === 'completed' const action = job.type === 'archive_extract' ? 'extraction' : 'compression' - await createNotification(db, { + await deps.notifications.create({ userId: job.userId, type: completed ? 'archive_job_completed' : 'archive_job_failed', title: completed ? `File ${action} completed` : `File ${action} failed`, diff --git a/server/services/branding.ts b/server/usecases/branding.ts similarity index 70% rename from server/services/branding.ts rename to server/usecases/branding.ts index 6dd118a1..645603ba 100644 --- a/server/services/branding.ts +++ b/server/usecases/branding.ts @@ -1,12 +1,13 @@ -import { eq, inArray } from 'drizzle-orm' -import { type BrandingThemeConfig, type BrandingThemeMode, isBrandingThemePresetId } from '../../shared/types' -import { systemOptions } from '../db/schema' +import { + type BrandingConfig, + type BrandingThemeConfig, + type BrandingThemeMode, + isBrandingThemePresetId, +} from '@shared/types' import { mimeToExt } from '../lib/mime-utils' -import type { Database, Platform } from '../platform/interface' -import { S3Service } from './s3' -import { type Storage as S3Storage, selectStorage } from './storage' +import type { S3Gateway, StorageRecord, StorageRepo, SystemOptionsRepo } from './ports' -const s3 = new S3Service() +export type BrandingDeps = { s3: S3Gateway; storages: StorageRepo; systemOptions: SystemOptionsRepo } const LOGO_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'] as const const FAVICON_MIMES = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/svg+xml'] as const @@ -38,10 +39,10 @@ const THEME_KEYS = [ export type BrandingUploadResult = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string } -export async function readBranding(db: Database) { +export async function readBranding(deps: BrandingDeps): Promise { const keys = Object.values(BRANDING_KEYS) - const rows = await db.select().from(systemOptions).where(inArray(systemOptions.key, keys)) - const map = new Map(rows.map((r) => [r.key, r.value])) + const rows = await deps.systemOptions.listByKeyLike('branding_%') + const map = new Map(rows.filter((r) => (keys as readonly string[]).includes(r.key)).map((r) => [r.key, r.value])) const configured = THEME_KEYS.some((field) => map.has(BRANDING_KEYS[field])) const mode = readThemeMode(map.get(BRANDING_KEYS.theme_mode)) const preset = readThemePreset(map.get(BRANDING_KEYS.theme_preset)) @@ -61,7 +62,7 @@ export async function readBranding(db: Database) { } export async function uploadBrandingImage( - platform: Platform, + deps: BrandingDeps, field: 'logo' | 'favicon', file: File, ): Promise { @@ -73,9 +74,9 @@ export async function uploadBrandingImage( return { ok: false, status: 413, error: 'File too large. Max 2 MiB.' } } - let storage: S3Storage + let storage: StorageRecord try { - storage = await selectStorage(platform.db, 'public') + storage = await deps.storages.select('public') } catch { return { ok: false, status: 503, error: 'No public storage configured' } } @@ -83,32 +84,29 @@ export async function uploadBrandingImage( const ext = mimeToExt(file.type) const key = `_system/branding/${field}.${ext}` const bytes = new Uint8Array(await file.arrayBuffer()) - await s3.putObject(storage, key, bytes, file.type) - const url = s3.getPublicUrl(storage, key) + await deps.s3.putObject(storage, key, bytes, file.type) + const url = deps.s3.getPublicUrl(storage, key) - await upsertOption(platform.db, BRANDING_KEYS[field], url) + await deps.systemOptions.set(BRANDING_KEYS[field], url, true) return { ok: true, url } } export async function setBrandingField( - db: Database, + deps: BrandingDeps, field: 'wordmark_text' | 'hide_powered_by' | (typeof THEME_KEYS)[number], value: string, ): Promise { - await upsertOption(db, BRANDING_KEYS[field], value) + await deps.systemOptions.set(BRANDING_KEYS[field], value, true) } -export async function resetBrandingField(db: Database, field: keyof typeof BRANDING_KEYS): Promise { - await db.delete(systemOptions).where(eq(systemOptions.key, BRANDING_KEYS[field])) +export async function resetBrandingField(deps: BrandingDeps, field: keyof typeof BRANDING_KEYS): Promise { + await deps.systemOptions.delete(BRANDING_KEYS[field]) } -export async function resetBrandingTheme(db: Database): Promise { - await db.delete(systemOptions).where( - inArray( - systemOptions.key, - THEME_KEYS.map((field) => BRANDING_KEYS[field]), - ), - ) +export async function resetBrandingTheme(deps: BrandingDeps): Promise { + for (const field of THEME_KEYS) { + await deps.systemOptions.delete(BRANDING_KEYS[field]) + } } function readThemeMode(value: string | undefined): BrandingThemeMode { @@ -134,10 +132,3 @@ function readCustomTheme(map: Map) { ring_color: ringColor, } } - -async function upsertOption(db: Database, key: string, value: string): Promise { - await db - .insert(systemOptions) - .values({ key, value, public: true }) - .onConflictDoUpdate({ target: systemOptions.key, set: { value, public: true } }) -} diff --git a/server/usecases/captcha.ts b/server/usecases/captcha.ts new file mode 100644 index 00000000..6655cbfb --- /dev/null +++ b/server/usecases/captcha.ts @@ -0,0 +1,19 @@ +import { type CaptchaConfig, type CaptchaOptionValues, readCaptchaConfig } from '../domain/captcha' +import type { SystemOptionsRepo } from './ports' + +export type CaptchaDeps = { systemOptions: SystemOptionsRepo } + +async function loadCaptchaOptionValuesFromRepo(systemOptions: SystemOptionsRepo): Promise { + const rows = await systemOptions.listByKeyLike('captcha_%') + const values: CaptchaOptionValues = {} + for (const row of rows) values[row.key] = row.value + return values +} + +export async function loadCaptchaOptionValues(deps: CaptchaDeps): Promise { + return loadCaptchaOptionValuesFromRepo(deps.systemOptions) +} + +export async function loadCaptchaConfig(deps: CaptchaDeps): Promise { + return readCaptchaConfig(await loadCaptchaOptionValuesFromRepo(deps.systemOptions)) +} diff --git a/server/services/cloud-traffic-metering.test.ts b/server/usecases/cloud-traffic-metering.test.ts similarity index 84% rename from server/services/cloud-traffic-metering.test.ts rename to server/usecases/cloud-traffic-metering.test.ts index 92882fbd..4f04f4f7 100644 --- a/server/services/cloud-traffic-metering.test.ts +++ b/server/usecases/cloud-traffic-metering.test.ts @@ -1,16 +1,64 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' +import { createLicensingCloudGateway } from '../adapters/gateways/licensing-cloud' +import { createCloudTrafficReportRepo } from '../adapters/repos/cloud-traffic-report' +import { createLicenseBindingRepo } from '../adapters/repos/license-binding' import { cloudTrafficReports } from '../db/schema' -import { createLicenseBinding } from '../licensing/license-state' +import type { Database, Platform } from '../platform/interface' import { createTestApp } from '../test/setup' -import { CloudTrafficBlockedError, reportTrafficEgress, syncPendingCloudTrafficReports } from './cloud-traffic-metering' +import { + CloudTrafficBlockedError, + type CloudTrafficMeteringDeps, + reportTrafficEgress as reportTrafficEgressUsecase, + syncPendingCloudTrafficReports as syncPendingCloudTrafficReportsUsecase, + type TrafficReportSource, +} from './cloud-traffic-metering' const hasFeatureMock = vi.hoisted(() => vi.fn(() => true)) -vi.mock('../licensing/has-feature', () => ({ +vi.mock('../domain/licensing', () => ({ hasFeature: hasFeatureMock, +})) +vi.mock('./licensing', () => ({ loadBindingState: vi.fn(async () => ({ bound: true, active: true, edition: 'business', features: ['quota_store'] })), })) +function meteringDeps(db: Database): CloudTrafficMeteringDeps { + return { + licenseBinding: createLicenseBindingRepo(db), + licensingCloud: createLicensingCloudGateway(), + cloudTrafficReports: createCloudTrafficReportRepo(db), + } +} + +function cloudBaseUrlOf(platform: Platform): string { + return platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT +} + +// Adapters preserving the pre-migration call shape (the service took `platform`; +// the usecase takes `deps` + an explicit cloudBaseUrl). +function reportTrafficEgress(params: { + platform: Platform + orgId: string + bytes: number + storageId?: string | null + egressCreditBillingEnabled?: boolean + egressCreditUnitBytes?: number | null + egressCreditPerUnit?: number | null + source: TrafficReportSource + sourceId: string + eventId?: string + now?: Date +}) { + const { platform, ...rest } = params + return reportTrafficEgressUsecase(meteringDeps(platform.db), { cloudBaseUrl: cloudBaseUrlOf(platform), ...rest }) +} + +function syncPendingCloudTrafficReports(params: { db: Database; cloudBaseUrl: string; limit?: number; now?: Date }) { + const { db, ...rest } = params + return syncPendingCloudTrafficReportsUsecase(meteringDeps(db), rest) +} + function makeResponse(body: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, @@ -27,7 +75,7 @@ function headerValue(headers: HeadersInit | undefined, name: string): string | n async function seedTrafficBinding(db: Awaited>['db']) { const issuedAt = Math.floor(Date.now() / 1000) const expiresAt = issuedAt + 3600 - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'test-binding', cloudStoreId: 'store-test-binding', instanceId: 'test-instance', diff --git a/server/services/cloud-traffic-metering.ts b/server/usecases/cloud-traffic-metering.ts similarity index 50% rename from server/services/cloud-traffic-metering.ts rename to server/usecases/cloud-traffic-metering.ts index bf2e2046..80a34638 100644 --- a/server/services/cloud-traffic-metering.ts +++ b/server/usecases/cloud-traffic-metering.ts @@ -1,21 +1,24 @@ -import { asc, eq, inArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { z } from 'zod' -import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' -import { cloudTrafficReports } from '../db/schema' -import { hasFeature, loadBindingState } from '../licensing/has-feature' -import { loadActiveLicenseBinding } from '../licensing/license-state' -import type { Database, Platform } from '../platform/interface' -import { currentTrafficPeriod } from './effective-quota' -import { createBoundCloudClient, requestCloudJson } from './licensing-cloud' +import { hasFeature } from '../domain/licensing' +import { currentTrafficPeriod } from '../domain/quota' +import { loadBindingState } from './licensing' +import type { + CloudTrafficReportRecord, + CloudTrafficReportRepo, + CloudTrafficReportStatus, + LicenseBindingRepo, + LicensingCloudGateway, + TrafficReportSource, +} from './ports' -export type TrafficReportSource = - | 'object_download' - | 'direct_share' - | 'landing_share' - | 'image_hosting' - | 'custom_domain_image' - | 'webdav_download' +export type { TrafficReportSource } from './ports' + +export type CloudTrafficMeteringDeps = { + licenseBinding: LicenseBindingRepo + licensingCloud: LicensingCloudGateway + cloudTrafficReports: CloudTrafficReportRepo +} export class CloudTrafficBlockedError extends Error { constructor() { @@ -30,26 +33,26 @@ const usageResponseSchema = z.object({ eventId: z.string().min(1), }) -type ReportStatus = 'pending' | 'reported' | 'skipped_unbound' | 'blocked' | 'failed' -type TrafficReport = typeof cloudTrafficReports.$inferSelect - -export async function reportTrafficEgress(params: { - platform: Platform - orgId: string - bytes: number - storageId?: string | null - egressCreditBillingEnabled?: boolean - egressCreditUnitBytes?: number | null - egressCreditPerUnit?: number | null - source: TrafficReportSource - sourceId: string - eventId?: string - now?: Date -}): Promise<{ status: ReportStatus; eventId: string; duplicate: boolean }> { - const { platform, orgId, bytes, source, sourceId, now = new Date() } = params +export async function reportTrafficEgress( + deps: CloudTrafficMeteringDeps, + params: { + cloudBaseUrl: string + orgId: string + bytes: number + storageId?: string | null + egressCreditBillingEnabled?: boolean + egressCreditUnitBytes?: number | null + egressCreditPerUnit?: number | null + source: TrafficReportSource + sourceId: string + eventId?: string + now?: Date + }, +): Promise<{ status: CloudTrafficReportStatus; eventId: string; duplicate: boolean }> { + const { orgId, bytes, source, sourceId, now = new Date() } = params if (bytes <= 0) return { status: 'reported', eventId: params.eventId ?? '', duplicate: false } if (!params.egressCreditBillingEnabled) return { status: 'reported', eventId: params.eventId ?? '', duplicate: false } - if (!hasFeature('quota_store', await loadBindingState(platform.db))) { + if (!hasFeature('quota_store', await loadBindingState(deps))) { return { status: 'reported', eventId: params.eventId ?? '', duplicate: false } } if (!params.storageId || !params.egressCreditUnitBytes || !params.egressCreditPerUnit) { @@ -57,7 +60,7 @@ export async function reportTrafficEgress(params: { } const eventId = params.eventId ?? `traffic_${nanoid()}` - const existing = await loadTrafficReport(platform.db, eventId) + const existing = await deps.cloudTrafficReports.findByEventId(eventId) const period = existing?.period ?? currentTrafficPeriod(now) if (existing) { assertSameReport(existing, { @@ -71,11 +74,11 @@ export async function reportTrafficEgress(params: { creditsPerUnit: params.egressCreditPerUnit, }) if (existing.status !== 'blocked') { - return { status: existing.status as ReportStatus, eventId, duplicate: true } + return { status: existing.status, eventId, duplicate: true } } if (existing.status === 'blocked') throw new CloudTrafficBlockedError() } else { - await insertTrafficReport(platform, { + await deps.cloudTrafficReports.insert({ orgId, period, source, @@ -90,47 +93,38 @@ export async function reportTrafficEgress(params: { }) } - const binding = await loadActiveLicenseBinding(platform.db) + const binding = await deps.licenseBinding.loadActiveLicenseBinding() if (!binding?.refreshToken || !binding.cloudStoreId) { - await updateTrafficReport(platform.db, eventId, 'skipped_unbound', null, now) + await deps.cloudTrafficReports.updateStatus(eventId, 'skipped_unbound', null, now) return { status: 'skipped_unbound', eventId, duplicate: false } } - const status = await syncTrafficReport({ - db: platform.db, - cloudBaseUrl: platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT, + const status = await syncTrafficReport(deps, { + cloudBaseUrl: params.cloudBaseUrl, refreshToken: binding.refreshToken, storeId: binding.cloudStoreId, - report: (await loadTrafficReport(platform.db, eventId))!, + report: (await deps.cloudTrafficReports.findByEventId(eventId))!, now, }) if (status === 'blocked') throw new CloudTrafficBlockedError() return { status, eventId, duplicate: false } } -export async function syncPendingCloudTrafficReports(params: { - db: Database - cloudBaseUrl: string - limit?: number - now?: Date -}): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> { - const { db, cloudBaseUrl, limit = 100, now = new Date() } = params - if (!hasFeature('quota_store', await loadBindingState(db))) +export async function syncPendingCloudTrafficReports( + deps: CloudTrafficMeteringDeps, + params: { cloudBaseUrl: string; limit?: number; now?: Date }, +): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> { + const { cloudBaseUrl, limit = 100, now = new Date() } = params + if (!hasFeature('quota_store', await loadBindingState(deps))) return { attempted: 0, reported: 0, blocked: 0, failed: 0 } - const binding = await loadActiveLicenseBinding(db) + const binding = await deps.licenseBinding.loadActiveLicenseBinding() if (!binding?.refreshToken || !binding.cloudStoreId) return { attempted: 0, reported: 0, blocked: 0, failed: 0 } - const reports = await db - .select() - .from(cloudTrafficReports) - .where(inArray(cloudTrafficReports.status, ['pending', 'failed'])) - .orderBy(asc(cloudTrafficReports.createdAt)) - .limit(limit) + const reports = await deps.cloudTrafficReports.listPending(limit) const result = { attempted: reports.length, reported: 0, blocked: 0, failed: 0 } for (const report of reports) { - const status = await syncTrafficReport({ - db, + const status = await syncTrafficReport(deps, { cloudBaseUrl, refreshToken: binding.refreshToken, storeId: binding.cloudStoreId, @@ -142,17 +136,19 @@ export async function syncPendingCloudTrafficReports(params: { return result } -async function syncTrafficReport(params: { - db: Database - cloudBaseUrl: string - refreshToken: string - storeId: string - report: TrafficReport - now: Date -}): Promise<'reported' | 'blocked' | 'failed'> { - const { db, cloudBaseUrl, refreshToken, storeId, report, now } = params +async function syncTrafficReport( + deps: CloudTrafficMeteringDeps, + params: { + cloudBaseUrl: string + refreshToken: string + storeId: string + report: CloudTrafficReportRecord + now: Date + }, +): Promise<'reported' | 'blocked' | 'failed'> { + const { cloudBaseUrl, refreshToken, storeId, report, now } = params try { - const client = createBoundCloudClient(cloudBaseUrl, refreshToken) + const client = deps.licensingCloud.createBoundCloudClient(cloudBaseUrl, refreshToken) const isStorageEgress = Boolean(report.storageId && report.unitBytes && report.creditsPerUnit) const payload = isStorageEgress ? { @@ -174,7 +170,7 @@ async function syncTrafficReport(params: { idempotencyKey: report.eventId, customerId: report.orgId, } - const response = await requestCloudJson( + const response = await deps.licensingCloud.requestCloudJson( client.stores[':storeId'].billing['usage-events'].$post({ param: { storeId }, json: payload as never, @@ -182,26 +178,21 @@ async function syncTrafficReport(params: { usageResponseSchema, ) if (!response.accepted) throw new Error('cloud_usage_report_rejected') - await updateTrafficReport(db, report.eventId, 'reported', null, now) + await deps.cloudTrafficReports.updateStatus(report.eventId, 'reported', null, now) return 'reported' } catch (error) { const message = error instanceof Error ? error.message : 'cloud_usage_report_failed' if (message === 'insufficient_credits' || message === 'overage_cap_exceeded') { - await updateTrafficReport(db, report.eventId, 'blocked', message, now) + await deps.cloudTrafficReports.updateStatus(report.eventId, 'blocked', message, now) return 'blocked' } - await updateTrafficReport(db, report.eventId, 'failed', message, now) + await deps.cloudTrafficReports.updateStatus(report.eventId, 'failed', message, now) return 'failed' } } -async function loadTrafficReport(db: Database, eventId: string) { - const rows = await db.select().from(cloudTrafficReports).where(eq(cloudTrafficReports.eventId, eventId)).limit(1) - return rows[0] -} - function assertSameReport( - report: TrafficReport, + report: CloudTrafficReportRecord, params: { orgId: string period: string @@ -226,50 +217,3 @@ function assertSameReport( throw new Error('traffic_report_idempotency_conflict') } } - -async function insertTrafficReport( - platform: Platform, - params: { - orgId: string - period: string - source: TrafficReportSource - sourceId: string - eventId: string - bytes: number - storageId: string - unitBytes: number - creditsPerUnit: number - status: ReportStatus - now: Date - }, -) { - await platform.db.insert(cloudTrafficReports).values({ - id: nanoid(), - orgId: params.orgId, - period: params.period, - source: params.source, - sourceId: params.sourceId, - eventId: params.eventId, - bytes: params.bytes, - storageId: params.storageId, - unitBytes: params.unitBytes, - creditsPerUnit: params.creditsPerUnit, - status: params.status, - error: null, - createdAt: params.now, - updatedAt: params.now, - }) -} - -async function updateTrafficReport( - db: Database, - eventId: string, - status: ReportStatus, - error: string | null, - now: Date, -) { - await db - .update(cloudTrafficReports) - .set({ status, error, updatedAt: now }) - .where(eq(cloudTrafficReports.eventId, eventId)) -} diff --git a/server/usecases/deps.ts b/server/usecases/deps.ts new file mode 100644 index 00000000..b10931f4 --- /dev/null +++ b/server/usecases/deps.ts @@ -0,0 +1,95 @@ +// The Deps interface aggregates every port. Usecases take `deps` as their first +// argument and reach the outside world only through it; http routes read it from +// context (`c.get('deps')`). It is assembled in composition.ts. + +import type { + ActivityRepo, + AnnouncementRepo, + ApiKeyGateway, + ArchiveJobsGateway, + ArchiveTargetFolderRepo, + BackgroundJobRepo, + CfHostnamesProvider, + ChangelogProvider, + CloudStoreRepo, + CloudTrafficReportRepo, + DownloaderRepo, + DownloadTaskRepo, + DownloadTokenGateway, + EmailGateway, + ImageHostingConfigRepo, + ImageHostingRepo, + ImageUpload, + InstanceRepo, + InviteRepo, + LicenseBindingRepo, + LicensingCloudGateway, + MatterRepo, + MemberCountRepo, + NotificationRepo, + ObjectUploadSessionRepo, + OrgRepo, + ProfileRepo, + QuotaRepo, + RemoteDownloadUsageRepo, + S3Gateway, + ShareNotificationRepo, + ShareRepo, + SiteInvitationRepo, + StorageRepo, + StorageUsageRepo, + SystemOptionsRepo, + TeamInviteRepo, + TeamRepo, + UserAdminRepo, + WebDavPathRepo, + WebDavStateRepo, + ZipGateway, + ZipPlanRepo, +} from './ports' + +export interface Deps { + activity: ActivityRepo + announcements: AnnouncementRepo + apiKeys: ApiKeyGateway + archiveJobs: ArchiveJobsGateway + archiveTargetFolders: ArchiveTargetFolderRepo + backgroundJobs: BackgroundJobRepo + cfHostnames: CfHostnamesProvider + changelog: ChangelogProvider + cloudStore: CloudStoreRepo + cloudTrafficReports: CloudTrafficReportRepo + downloaders: DownloaderRepo + downloadTasks: DownloadTaskRepo + downloadTokens: DownloadTokenGateway + email: EmailGateway + invites: InviteRepo + imageHostingConfigs: ImageHostingConfigRepo + imageHosting: ImageHostingRepo + imageUpload: ImageUpload + instance: InstanceRepo + licenseBinding: LicenseBindingRepo + licensingCloud: LicensingCloudGateway + matter: MatterRepo + memberCount: MemberCountRepo + notifications: NotificationRepo + objectUploadSessions: ObjectUploadSessionRepo + org: OrgRepo + profiles: ProfileRepo + quota: QuotaRepo + remoteDownloadUsage: RemoteDownloadUsageRepo + s3: S3Gateway + shareNotifications: ShareNotificationRepo + share: ShareRepo + siteInvitations: SiteInvitationRepo + storages: StorageRepo + storageUsage: StorageUsageRepo + systemOptions: SystemOptionsRepo + teams: TeamRepo + teamInvites: TeamInviteRepo + userAdmin: UserAdminRepo + webdavPath: WebDavPathRepo + webdavState: WebDavStateRepo + zip: ZipGateway + zipPlan: ZipPlanRepo +} diff --git a/server/usecases/downloads.ts b/server/usecases/downloads.ts new file mode 100644 index 00000000..7416ca79 --- /dev/null +++ b/server/usecases/downloads.ts @@ -0,0 +1,616 @@ +import type { + CreateDownloaderInput, + CreateDownloadTaskInput, + DownloaderHeartbeatInput, + DownloadTaskActionInput, + UpdateDownloaderInput, + UpdateDownloadTaskInput, +} from '@shared/schemas' +import { downloadTaskRuntimeSchema } from '@shared/schemas' +import type { Downloader, DownloadTask, DownloadTaskRuntime } from '@shared/types' +import { nanoid } from 'nanoid' +import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' +import type { Platform } from '../platform/interface' +import type { + DownloaderRecord, + DownloaderRepo, + DownloadTaskRecord, + DownloadTaskRepo, + DownloadTokenGateway, + LicenseBindingRepo, + LicensingCloudGateway, + ListDownloadTasksFilters, + RemoteDownloadUsageRepo, +} from './ports' +import { DownloadError } from './ports' +import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage' + +// Pure orchestration over the downloader / download-task repos: registration, +// the queue assignment + stale-lease recovery loop, the task state machine, and +// remote-download credit billing. Reaches the DB only through the repos; token +// signing and the cloud URL come from the platform (the download-token gateway +// is platform-per-call, mirroring auth.ts). + +export type DownloadsDeps = { + downloaders: DownloaderRepo + downloadTasks: DownloadTaskRepo + downloadTokens: DownloadTokenGateway + licenseBinding: LicenseBindingRepo + licensingCloud: LicensingCloudGateway + remoteDownloadUsage: RemoteDownloadUsageRepo +} + +const DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES = 100 * 1024 * 1024 +const UPLOAD_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60 +const DOWNLOADER_HEARTBEAT_LEASE_MS = 30_000 +const QUEUE_ASSIGN_BATCH = 20 + +const PAUSABLE_TASK_STATUSES = ['queued', 'assigned', 'downloading'] as const +const CANCELABLE_TASK_STATUSES = [ + 'queued', + 'assigned', + 'downloading', + 'suspended', + 'paused', + 'interrupted', + 'uploading', + 'pausing', +] as const +const TERMINAL_TASK_STATUSES = ['completed', 'failed', 'canceled'] as const +const EXECUTABLE_TASK_STATUSES = ['queued', 'assigned', 'downloading', 'uploading'] as const +const RESTARTABLE_TASK_STATUSES = [ + 'queued', + 'assigned', + 'paused', + 'interrupted', + 'suspended', + 'failed', + 'canceled', + 'completed', +] as const +const DOWNLOADER_TOKEN_TASK_STATUSES = ['assigned', 'downloading', 'uploading', 'interrupted'] as const +const DELETE_DOWNLOADER_REQUEUE_STATUSES = [ + 'queued', + 'assigned', + 'downloading', + 'suspended', + 'pausing', + 'paused', + 'interrupted', + 'uploading', + 'canceling', +] +const STALE_REQUEUE_STATUSES = ['assigned', 'downloading', 'uploading', 'interrupted'] + +// ─── Downloader registration / admin CRUD ─────────────────────────────────── + +export async function createDownloader( + deps: DownloadsDeps, + platform: Platform, + input: CreateDownloaderInput, + userId: string, +): Promise<{ downloader: Downloader; token: string }> { + const now = new Date() + const id = nanoid() + const jti = nanoid() + const token = await deps.downloadTokens.signDownloadToken(platform, { + v: 1, + typ: 'downloader', + downloaderId: id, + jti, + iat: Math.floor(now.getTime() / 1000), + }) + await deps.downloaders.insert({ + id, + name: input.name, + tokenHash: await deps.downloadTokens.hashDownloadToken(platform, token), + tokenJti: jti, + version: input.heartbeat.version, + hostname: input.heartbeat.hostname, + platform: input.heartbeat.platform, + arch: input.heartbeat.arch, + engine: input.heartbeat.engine, + capabilities: input.heartbeat.capabilities, + maxConcurrentTasks: input.heartbeat.maxConcurrentTasks, + currentTasks: input.heartbeat.currentTasks, + downloadBps: input.heartbeat.downloadBps, + uploadBps: input.heartbeat.uploadBps, + freeDiskBytes: input.heartbeat.freeDiskBytes, + remoteDownloadCreditUnitBytes: DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES, + createdBy: userId, + now, + }) + return { downloader: await deps.downloaders.get(id), token } +} + +export async function listDownloaders(deps: DownloadsDeps): Promise { + await recoverStaleDownloaderAssignments(deps) + return deps.downloaders.list() +} + +export function getDownloader(deps: DownloadsDeps, id: string): Promise { + return deps.downloaders.get(id) +} + +export async function updateDownloader( + deps: DownloadsDeps, + id: string, + input: UpdateDownloaderInput, +): Promise { + await deps.downloaders.getRecord(id) // throws not_found + await deps.downloaders.update(id, input, new Date()) + return deps.downloaders.get(id) +} + +export async function deleteDownloader(deps: DownloadsDeps, id: string): Promise<{ id: string; deleted: true }> { + await deps.downloaders.getRecord(id) // throws not_found + const now = new Date() + await deps.downloadTasks.requeueAssignedTo(id, DELETE_DOWNLOADER_REQUEUE_STATUSES, now) + await deps.downloaders.delete(id) + return { id, deleted: true } +} + +export async function recordDownloaderHeartbeat( + deps: DownloadsDeps, + downloaderId: string, + heartbeat: DownloaderHeartbeatInput, +): Promise { + const downloader = await deps.downloaders.getRecord(downloaderId) // throws not_found + await deps.downloaders.recordHeartbeat(downloaderId, heartbeat, downloader.enabled, new Date()) + await assignQueuedTasks(deps) + return deps.downloaders.get(downloaderId) +} + +// ─── Download task CRUD ────────────────────────────────────────────────────── + +export async function createDownloadTask( + deps: DownloadsDeps, + orgId: string, + userId: string, + input: CreateDownloadTaskInput, +): Promise { + const now = new Date() + const id = nanoid() + await recoverStaleDownloaderAssignments(deps) + const assigned = await selectDownloader(deps, input.source.type) + await deps.downloadTasks.insert({ + id, + orgId, + createdByUserId: userId, + sourceType: input.source.type, + sourceUri: input.source.uri, + displayName: input.name ?? null, + targetFolder: input.targetFolder, + category: input.category ?? null, + tags: input.tags ?? [], + assignedDownloaderId: assigned?.id ?? null, + status: assigned ? 'assigned' : 'queued', + assignedAt: assigned ? now : null, + now, + }) + return deps.downloadTasks.get(orgId, id) +} + +export async function listDownloadTasks( + deps: DownloadsDeps, + platform: Platform, + opts: ListDownloadTasksFilters & { includeUploadToken?: boolean }, +): Promise<{ items: DownloadTask[]; total: number }> { + const { items, total, rows } = await deps.downloadTasks.list(opts) + if (!opts.includeUploadToken) return { items, total } + const decorated = await Promise.all( + items.map((task, index) => decorateWithUploadToken(deps, platform, task, rows[index])), + ) + return { items: decorated, total } +} + +export function getDownloadTask(deps: DownloadsDeps, orgId: string, id: string): Promise { + return deps.downloadTasks.get(orgId, id) +} + +export async function updateDownloadTask( + deps: DownloadsDeps, + platform: Platform, + id: string, + input: UpdateDownloadTaskInput, + actor: { orgId?: string; downloaderId?: string }, +): Promise { + const task = await deps.downloadTasks.findRecord(id) + if (!task) throw new DownloadError('not_found') + if (actor.orgId && task.orgId !== actor.orgId) throw new DownloadError('not_found') + if (actor.downloaderId && task.assignedDownloaderId !== actor.downloaderId) throw new DownloadError('forbidden') + + const now = new Date() + if (actor.downloaderId && task.status === 'pausing' && input.status === 'paused') { + await deps.downloadTasks.setFields(id, { + status: 'paused', + runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), + updatedAt: now, + }) + return deps.downloadTasks.get(task.orgId, id) + } + if (actor.downloaderId && task.status === 'canceling' && input.status === 'canceled') { + await deps.downloadTasks.setFields(id, { + status: 'canceled', + runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), + finishedAt: task.finishedAt ?? now, + updatedAt: now, + }) + return deps.downloadTasks.get(task.orgId, id) + } + if (actor.downloaderId && ['pausing', 'paused', 'canceling', 'canceled'].includes(task.status)) { + throw new DownloadError('invalid_state', `Task is ${task.status}`) + } + if (actor.orgId && !actor.downloaderId) { + const onlyCancel = + input.status === 'canceled' && + input.progress === undefined && + input.errorMessage === undefined && + input.resultObjectId === undefined && + input.runtime === undefined + if (!onlyCancel) throw new DownloadError('forbidden') + } + if (actor.downloaderId && isRetainedSeedReport(input) && task.status !== 'completed') { + return deps.downloadTasks.get(task.orgId, id) + } + + let status = input.status ?? task.status + let billingAuthorizedBytes = task.billingAuthorizedBytes + let billingChargedBytes = task.billingChargedBytes + let billingChargedCredits = task.billingChargedCredits + let billingStatus = task.billingStatus + const currentRuntime = parseTaskRuntime(task.runtime) + const nextRuntime = nextTaskRuntime(currentRuntime, input.runtime, input.progress, status, now) + const currentDownloadedBytes = currentRuntime?.progress?.download.bytes ?? 0 + const nextDownloadedBytes = nextRuntime?.progress?.download.bytes ?? currentDownloadedBytes + + if (actor.downloaderId && nextDownloadedBytes > currentDownloadedBytes) { + const downloader = await deps.downloaders.getRecord(actor.downloaderId) + const targetUnits = Math.ceil(nextDownloadedBytes / downloader.remoteDownloadCreditUnitBytes) + const currentUnits = Math.ceil(task.billingChargedBytes / downloader.remoteDownloadCreditUnitBytes) + const cloudBaseUrl = platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT + try { + for (let unit = currentUnits + 1; unit <= targetUnits; unit += 1) { + await reportRemoteDownloadUnit(deps, { + cloudBaseUrl, + orgId: task.orgId, + downloaderId: actor.downloaderId, + taskId: task.id, + unitIndex: unit, + unitBytes: downloader.remoteDownloadCreditUnitBytes, + creditsPerUnit: downloader.remoteDownloadCreditPerUnit, + enabled: downloader.remoteDownloadCreditBillingEnabled, + }) + billingChargedCredits += downloader.remoteDownloadCreditBillingEnabled + ? downloader.remoteDownloadCreditPerUnit + : 0 + } + if (targetUnits > currentUnits) { + billingChargedBytes = targetUnits * downloader.remoteDownloadCreditUnitBytes + billingAuthorizedBytes = billingChargedBytes + billingStatus = 'ok' + } + } catch (error) { + if (error instanceof RemoteDownloadBillingBlockedError) { + status = 'suspended' + billingStatus = 'insufficient_credits' + } else { + throw error + } + } + } + + const nextFinishedAt = + task.finishedAt ?? (input.status !== undefined && ['completed', 'failed', 'canceled'].includes(status) ? now : null) + + await deps.downloadTasks.setFields(id, { + status, + billingAuthorizedBytes, + billingChargedBytes, + billingChargedCredits, + billingStatus, + errorMessage: input.errorMessage === undefined ? task.errorMessage : input.errorMessage, + resultObjectId: input.resultObjectId === undefined ? task.resultObjectId : input.resultObjectId, + runtime: serializeTaskRuntime(nextRuntime), + startedAt: task.startedAt ?? (status === 'downloading' ? now : null), + finishedAt: nextFinishedAt, + updatedAt: now, + }) + + return deps.downloadTasks.get(task.orgId, id) +} + +// ─── Task action state machine ─────────────────────────────────────────────── + +export async function performDownloadTaskAction( + deps: DownloadsDeps, + orgId: string, + id: string, + action: DownloadTaskActionInput['action'], +): Promise { + const task = await deps.downloadTasks.getRecord(orgId, id) + + if (action === 'delete') { + if (!TERMINAL_TASK_STATUSES.includes(task.status as (typeof TERMINAL_TASK_STATUSES)[number])) { + throw new DownloadError('invalid_state', 'Only completed, failed, or canceled tasks can be deleted') + } + await deps.downloadTasks.delete(id) + return { id, deleted: true } + } + + const now = new Date() + if (action === 'pause') { + if (task.status === 'paused') return deps.downloadTasks.get(orgId, id) + if (!PAUSABLE_TASK_STATUSES.includes(task.status as (typeof PAUSABLE_TASK_STATUSES)[number])) { + throw new DownloadError('invalid_state', 'Only queued, assigned, or downloading tasks can be paused') + } + const status = task.status === 'downloading' ? 'pausing' : 'paused' + await deps.downloadTasks.setFields(id, { + status, + runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), + updatedAt: now, + }) + return deps.downloadTasks.get(orgId, id) + } + + if (action === 'resume') { + if (!['paused', 'suspended'].includes(task.status)) { + throw new DownloadError('invalid_state', 'Only paused or suspended tasks can be resumed') + } + await deps.downloadTasks.setFields(id, { + status: 'queued', + assignedDownloaderId: null, + assignedAt: null, + runtime: clearTaskRuntimeMessageJson(task.runtime), + updatedAt: now, + }) + await assignQueuedTasks(deps) + return deps.downloadTasks.get(orgId, id) + } + + if (action === 'cancel') { + if (task.status === 'canceled') return deps.downloadTasks.get(orgId, id) + if (!CANCELABLE_TASK_STATUSES.includes(task.status as (typeof CANCELABLE_TASK_STATUSES)[number])) { + throw new DownloadError('invalid_state', 'Only active, interrupted, suspended, or paused tasks can be canceled') + } + const status = + task.assignedDownloaderId && + ['assigned', 'downloading', 'uploading', 'pausing', 'interrupted'].includes(task.status) + ? 'canceling' + : 'canceled' + await deps.downloadTasks.setFields(id, { + status, + runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)), + finishedAt: status === 'canceled' ? (task.finishedAt ?? now) : task.finishedAt, + updatedAt: now, + }) + return deps.downloadTasks.get(orgId, id) + } + + if (action === 'retry') { + if (task.status !== 'failed') { + throw new DownloadError('invalid_state', 'Only failed tasks can be retried') + } + await deps.downloadTasks.setFields(id, { + status: 'queued', + assignedDownloaderId: null, + errorCode: null, + errorMessage: null, + resultObjectId: null, + runtime: clearTaskRuntimeMessageJson(task.runtime), + assignedAt: null, + startedAt: null, + finishedAt: null, + updatedAt: now, + }) + await assignQueuedTasks(deps) + return deps.downloadTasks.get(orgId, id) + } + + if (action === 'restart') { + if (!RESTARTABLE_TASK_STATUSES.includes(task.status as (typeof RESTARTABLE_TASK_STATUSES)[number])) { + throw new DownloadError('invalid_state', 'Only inactive tasks can be restarted') + } + await deps.downloadTasks.setFields(id, { + status: 'queued', + assignedDownloaderId: null, + attempt: task.attempt + 1, + billingAuthorizedBytes: 0, + billingChargedBytes: 0, + billingChargedCredits: 0, + billingStatus: 'none', + errorCode: null, + errorMessage: null, + resultObjectId: null, + runtime: null, + assignedAt: null, + startedAt: null, + finishedAt: null, + updatedAt: now, + }) + await assignQueuedTasks(deps) + return deps.downloadTasks.get(orgId, id) + } + + throw new DownloadError('invalid_state') +} + +export async function assertTaskUploadAllowed( + deps: DownloadsDeps, + params: { taskId: string; downloaderId: string }, +): Promise { + const task = await deps.downloadTasks.findRecord(params.taskId) + if (!task || task.assignedDownloaderId !== params.downloaderId) throw new DownloadError('forbidden') + if (!['assigned', 'downloading', 'uploading'].includes(task.status)) throw new DownloadError('invalid_state') + return task +} + +// ─── Assignment / recovery ─────────────────────────────────────────────────── + +async function assignQueuedTasks(deps: DownloadsDeps): Promise { + await recoverStaleDownloaderAssignments(deps) + const tasks = await deps.downloadTasks.listQueued(QUEUE_ASSIGN_BATCH) + for (const task of tasks) { + const downloader = await selectDownloader(deps, task.sourceType) + if (!downloader) continue + const now = new Date() + await deps.downloadTasks.setFields(task.id, { + status: 'assigned', + assignedDownloaderId: downloader.id, + assignedAt: now, + updatedAt: now, + }) + } +} + +async function selectDownloader(deps: DownloadsDeps, sourceType: string): Promise { + const needed = sourceType === 'http' ? ['http'] : ['magnet', 'torrent'] + const leaseCutoff = new Date(Date.now() - DOWNLOADER_HEARTBEAT_LEASE_MS) + const candidates = await deps.downloaders.listAssignmentCandidates(leaseCutoff) + return candidates.find((c) => needed.some((capability) => c.capabilities.includes(capability))) ?? null +} + +async function recoverStaleDownloaderAssignments(deps: DownloadsDeps): Promise { + const now = new Date() + const leaseCutoff = new Date(now.getTime() - DOWNLOADER_HEARTBEAT_LEASE_MS) + const staleIds = await deps.downloaders.listStaleIds(leaseCutoff) + if (staleIds.length === 0) return + await deps.downloadTasks.requeueAssignedToMany(staleIds, STALE_REQUEUE_STATUSES, now) + await deps.downloaders.markStaleOffline(staleIds, now) +} + +// ─── Upload-token minting ──────────────────────────────────────────────────── + +async function decorateWithUploadToken( + deps: DownloadsDeps, + platform: Platform, + task: DownloadTask, + row: DownloadTaskRecord, +): Promise { + const include = DOWNLOADER_TOKEN_TASK_STATUSES.includes(row.status as (typeof DOWNLOADER_TOKEN_TASK_STATUSES)[number]) + if (!include || !task.status.assignment || !row.assignedAt) return task + task.status.assignment.uploadToken = await createTaskUploadToken(deps, platform, { + taskId: row.id, + downloaderId: task.status.assignment.downloaderId, + orgId: row.orgId, + targetFolder: row.targetFolder, + createdByUserId: row.createdByUserId, + assignedAt: row.assignedAt, + }) + return task +} + +function createTaskUploadToken( + deps: DownloadsDeps, + platform: Platform, + params: { + taskId: string + downloaderId: string + orgId: string + targetFolder: string + createdByUserId: string + assignedAt: Date + }, +): Promise { + const issuedAt = Math.floor(params.assignedAt.getTime() / 1000) + const exp = issuedAt + UPLOAD_TOKEN_TTL_SECONDS + return deps.downloadTokens.signDownloadToken(platform, { + v: 1, + typ: 'download-task-upload', + taskId: params.taskId, + downloaderId: params.downloaderId, + orgId: params.orgId, + targetFolder: params.targetFolder, + createdByUserId: params.createdByUserId, + scopes: ['objects:create', 'objects:upload', 'objects:confirm'], + jti: `${params.taskId}:${params.downloaderId}:${params.assignedAt.getTime()}`, + iat: issuedAt, + exp, + }) +} + +// ─── Runtime merge helpers ─────────────────────────────────────────────────── + +function isRetainedSeedReport(input: UpdateDownloadTaskInput): boolean { + return input.status === undefined && input.runtime?.phase === 'seeding' +} + +function nextTaskRuntime( + current: DownloadTaskRuntime | null, + input: UpdateDownloadTaskInput['runtime'], + progress: UpdateDownloadTaskInput['progress'], + status: string, + now: Date, +): DownloadTaskRuntime | null { + const runtime = input === undefined ? current : input + const merged = mergeTaskRuntime(runtime, progress, now) + if (!EXECUTABLE_TASK_STATUSES.includes(status as (typeof EXECUTABLE_TASK_STATUSES)[number])) { + return merged + } + return clearTaskRuntimeMessage(merged) +} + +function mergeTaskRuntime( + runtime: DownloadTaskRuntime | null | undefined, + progress: UpdateDownloadTaskInput['progress'], + now: Date, +): DownloadTaskRuntime | null { + const next = runtime ? { ...runtime } : null + if (!progress) return next + const base = next ?? {} + return { + ...base, + updatedAt: now.toISOString(), + progress: mergeTaskProgress(base.progress, progress), + } +} + +function mergeTaskProgress( + current: DownloadTaskRuntime['progress'] | undefined, + patch: UpdateDownloadTaskInput['progress'] | DownloadTaskRuntime['progress'] | undefined, +): NonNullable { + return { + download: { + bytes: Math.max(patch?.download?.bytes ?? 0, current?.download.bytes ?? 0), + totalBytes: patch?.download?.totalBytes ?? current?.download.totalBytes ?? null, + bytesPerSecond: patch?.download?.bytesPerSecond ?? current?.download.bytesPerSecond ?? 0, + }, + upload: { + bytes: Math.max(patch?.upload?.bytes ?? 0, current?.upload.bytes ?? 0), + totalBytes: patch?.upload?.totalBytes ?? current?.upload.totalBytes ?? null, + bytesPerSecond: patch?.upload?.bytesPerSecond ?? current?.upload.bytesPerSecond ?? 0, + }, + } +} + +function stoppedRuntime(value: string | null): DownloadTaskRuntime | null { + const runtime = parseTaskRuntime(value) + if (!runtime?.progress) return runtime + return { + ...runtime, + progress: { + download: { ...runtime.progress.download, bytesPerSecond: 0 }, + upload: { ...runtime.progress.upload, bytesPerSecond: 0 }, + }, + seeding: runtime.seeding ? { ...runtime.seeding, uploadBytesPerSecond: 0 } : runtime.seeding, + } +} + +function clearTaskRuntimeMessageJson(value: string | null): string | null { + return serializeTaskRuntime(clearTaskRuntimeMessage(parseTaskRuntime(value))) +} + +function clearTaskRuntimeMessage(runtime: DownloadTaskRuntime | null): DownloadTaskRuntime | null { + if (!runtime?.message) return runtime + const { message: _message, ...rest } = runtime + return Object.keys(rest).length > 0 ? rest : null +} + +function parseTaskRuntime(value: string | null): DownloadTaskRuntime | null { + if (!value) return null + return downloadTaskRuntimeSchema.parse(JSON.parse(value)) +} + +function serializeTaskRuntime(runtime: DownloadTaskRuntime | null | undefined): string | null { + return runtime && Object.keys(runtime).length > 0 ? JSON.stringify(runtime) : null +} diff --git a/server/licensing/e2e-cloud-integration.test.ts b/server/usecases/e2e-cloud-integration.test.ts similarity index 89% rename from server/licensing/e2e-cloud-integration.test.ts rename to server/usecases/e2e-cloud-integration.test.ts index 13a751b5..f837ea53 100644 --- a/server/licensing/e2e-cloud-integration.test.ts +++ b/server/usecases/e2e-cloud-integration.test.ts @@ -16,24 +16,20 @@ * the poll-pending stage against the live API, then use locally-signed PASETO * certs to test the full feature-gate chain end-to-end. * - * Run with: pnpm exec vitest run server/licensing/e2e-cloud-integration.test.ts + * Run with: pnpm exec vitest run server/usecases/e2e-cloud-integration.test.ts */ import { generateKeys, sign } from 'paseto-ts/v4' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SignupMode } from '../../shared/constants' -import { - CloudUnboundError, - createPairing, - type PairingResponse, - pollPairing, - refreshEntitlement, -} from '../services/licensing-cloud' +import { createPairing, pollPairing, refreshEntitlement } from '../adapters/gateways/licensing-cloud' +import { createInstanceRepo } from '../adapters/repos/instance' +import { createLicenseBindingRepo } from '../adapters/repos/license-binding' +import { PUBLIC_KEYS } from '../domain/license-keys' +import { hasFeature } from '../domain/licensing' import { adminHeaders, createTestApp, seedProLicense } from '../test/setup' -import { hasFeature, loadBindingState } from './has-feature' -import { getOrCreateInstanceId } from './instance-id' -import { createLicenseBinding, loadLicenseState } from './license-state' -import { PUBLIC_KEYS } from './public-keys' +import { loadBindingState } from './licensing' +import { CloudUnboundError, type PairingResponse } from './ports' const CLOUD_BASE_URL = process.env.ZPAN_CLOUD_URL ?? 'https://zpan-cloud-staging.saltbo.workers.dev' const CLOUD_BASE_ORIGIN = new URL(CLOUD_BASE_URL).origin @@ -160,7 +156,7 @@ describe('E2E: zpan-cloud API contract', () => { describe('E2E: Feature gates — Community (unbound)', () => { it('loadBindingState returns { bound: false } when no license', async () => { const { db } = await createTestApp() - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(state.bound).toBe(false) expect(state.active).toBeUndefined() @@ -171,7 +167,7 @@ describe('E2E: Feature gates — Community (unbound)', () => { it('hasFeature returns false for all Pro features when unbound', async () => { const { db } = await createTestApp() - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(hasFeature('open_registration', state)).toBe(false) expect(hasFeature('white_label', state)).toBe(false) @@ -203,7 +199,7 @@ describe('E2E: Feature gates — Pro (active binding)', () => { const { db } = await createTestApp() await seedProLicense(db) - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(state.bound).toBe(true) expect(state.active).toBe(true) @@ -216,7 +212,7 @@ describe('E2E: Feature gates — Pro (active binding)', () => { const { db } = await createTestApp() await seedProLicense(db) - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(hasFeature('open_registration', state)).toBe(true) expect(hasFeature('white_label', state)).toBe(true) @@ -242,7 +238,7 @@ describe('E2E: Feature gates — Pro (active binding)', () => { const { db } = await createTestApp() await seedProLicense(db) - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(hasFeature('white_label', state)).toBe(true) expect(hasFeature('open_registration', state)).toBe(true) @@ -258,7 +254,7 @@ describe('E2E: Feature gates — expired certificate', () => { PUBLIC_KEYS.unshift(E2E_PUBLIC) const expiresAt = nowSec() - 1 - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'expired-binding', cloudStoreId: 'store-expired', instanceId: 'test-instance', @@ -269,7 +265,7 @@ describe('E2E: Feature gates — expired certificate', () => { lastRefreshAt: nowSec(), }) - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(state.bound).toBe(true) expect(state.active).toBe(false) @@ -299,7 +295,10 @@ describe('E2E: Unbind flow', () => { expect(pollBody.status).toBe('approved') expect(pollBody.edition).toBe('pro') - let state = await loadBindingState(db, { cloudBaseUrl: CLOUD_BASE_URL, currentHost: 'localhost' }) + let state = await loadBindingState( + { licenseBinding: createLicenseBindingRepo(db) }, + { cloudBaseUrl: CLOUD_BASE_URL, currentHost: 'localhost' }, + ) expect(state.bound).toBe(true) expect(hasFeature('open_registration', state)).toBe(true) @@ -310,7 +309,10 @@ describe('E2E: Unbind flow', () => { expect(res.status).toBe(200) // Verify features are revoked after unbind - state = await loadBindingState(db, { cloudBaseUrl: CLOUD_BASE_URL, currentHost: 'localhost' }) + state = await loadBindingState( + { licenseBinding: createLicenseBindingRepo(db) }, + { cloudBaseUrl: CLOUD_BASE_URL, currentHost: 'localhost' }, + ) expect(state.bound).toBe(false) expect(hasFeature('open_registration', state)).toBe(false) }) @@ -364,7 +366,7 @@ describe('E2E: Full pairing-to-activation flow (mocked cloud approval)', () => { expect(pendingRes.status).toBe(200) expect(((await pendingRes.json()) as { status: string }).status).toBe('pending') - const instanceId = await getOrCreateInstanceId(db) + const instanceId = await createInstanceRepo(db).getOrCreateInstanceId() const cert = signLicenseAssertion({ subject: 'e2e-binding', instanceId, @@ -400,12 +402,12 @@ describe('E2E: Full pairing-to-activation flow (mocked cloud approval)', () => { expect(approvedBody.edition).toBe('pro') // Step 4: Verify binding stored in DB - const state2 = await loadLicenseState(db) + const state2 = await createLicenseBindingRepo(db).loadLicenseState() expect(state2.refreshToken).toBe('rt-e2e-secret') expect(state2.cachedCert).toBeTruthy() // Step 5: Verify features are now active - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(state.bound).toBe(true) expect(state.active).toBe(true) expect(state.edition).toBe('pro') @@ -431,7 +433,7 @@ describe('E2E: Full pairing-to-activation flow (mocked cloud approval)', () => { }) expect(unbindRes.status).toBe(200) - const stateAfter = await loadBindingState(db) + const stateAfter = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) expect(stateAfter.bound).toBe(false) expect(hasFeature('open_registration', stateAfter)).toBe(false) @@ -455,7 +457,7 @@ describe('E2E: PASETO cert verification chain', () => { const fakePaseto = 'v4.public.eyJhY2NvdW50X2lkIjoiYTEiLCJpbnN0YW5jZV9pZCI6InRlc3QtaW5zdGFuY2UiLCJwbGFuIjoicHJvIiwiZmVhdHVyZXMiOlsid2hpdGVfbGFiZWwiXSwiZXhwaXJlc19hdCI6IjIwOTktMDEtMDFUMDA6MDA6MDBaIiwiaXNzdWVkX2F0IjoiMjAyNi0wMS0wMVQwMDowMDowMFoifQ.fakesignaturebytes' - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'fake-binding', cloudStoreId: 'store-fake', instanceId: 'test-instance', @@ -466,7 +468,7 @@ describe('E2E: PASETO cert verification chain', () => { lastRefreshAt: nowSec(), }) - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: createLicenseBindingRepo(db) }) // Should be bound but inactive (PASETO verification fails) expect(state.bound).toBe(true) diff --git a/server/usecases/image-hosting.ts b/server/usecases/image-hosting.ts new file mode 100644 index 00000000..6b83656e --- /dev/null +++ b/server/usecases/image-hosting.ts @@ -0,0 +1,118 @@ +import type { AllowedImageMime } from '@shared/schemas' +import { + type ImageHostingRecord, + type ImageHostingRepo, + type QuotaRepo, + type S3Gateway, + StorageQuotaExceededError, + type StorageRecord, + type StorageUsageRepo, +} from './ports' +import { withStorageUsageReservation } from './storage-usage' + +export type ImageHostingDeps = { + imageHosting: ImageHostingRepo + storageUsage: StorageUsageRepo + quota: QuotaRepo + s3: S3Gateway +} + +export interface FinalizeImageHostingUploadInput { + orgId: string + storage: StorageRecord + path: string + mime: AllowedImageMime + bytes: Uint8Array +} + +// Server-side upload finalize (multipart / base64 tools): reserve quota, create a +// draft row, stream the bytes to S3, then flip the row active. Quota + the row + +// the object all roll back together if any step throws. +export async function finalizeImageHostingUpload( + deps: ImageHostingDeps, + input: FinalizeImageHostingUploadInput, +): Promise { + return withStorageUsageReservation( + deps, + { orgId: input.orgId, storageId: input.storage.id, bytes: input.bytes.byteLength }, + async (ctx) => { + const row = await deps.imageHosting.create({ + orgId: input.orgId, + path: input.path, + mime: input.mime, + size: input.bytes.byteLength, + storageId: input.storage.id, + status: 'draft', + }) + + ctx.onRollback(async () => { + await deps.imageHosting.delete(row.id, input.orgId) + await deps.s3.deleteObject(input.storage, row.storageKey) + }) + + await deps.s3.putObject(input.storage, row.storageKey, input.bytes, input.mime) + await deps.imageHosting.setActive(row.id, input.orgId) + + return row + }, + ) +} + +export type ConfirmImageHostingResult = { row: ImageHostingRecord | null; quotaExceeded?: boolean } + +// Browser two-stage flow: confirm a draft after the client uploaded to the +// presigned URL. Reserves quota then flips the row active; a lost race or a +// missing/non-draft row yields { row: null }. +export async function confirmImageHosting( + deps: ImageHostingDeps, + id: string, + orgId: string, +): Promise { + const existing = await deps.imageHosting.get(id, orgId) + if (!existing || existing.status !== 'draft') return { row: null } + + try { + return await withStorageUsageReservation( + deps, + { orgId, storageId: existing.storageId, bytes: existing.size }, + async () => { + const flipped = await deps.imageHosting.setActive(id, orgId) + if (!flipped) return { row: null } + return { row: { ...existing, status: 'active' as const } } + }, + ) + } catch (error) { + if (error instanceof StorageQuotaExceededError) return { row: null, quotaExceeded: true } + throw error + } +} + +export type DeleteImageHostingDeps = { imageHosting: ImageHostingRepo; storageUsage: StorageUsageRepo; s3: S3Gateway } + +// Delete a row and its S3 object (best-effort), then reconcile usage counters. +// Returns the deleted record, or null if it did not exist. +export async function deleteImageHosting( + deps: DeleteImageHostingDeps, + id: string, + orgId: string, + storage: StorageRecord | null, +): Promise { + const existing = await deps.imageHosting.get(id, orgId) + if (!existing) return null + + if (storage) { + try { + await deps.s3.deleteObject(storage, existing.storageKey) + } catch { + // Best-effort S3 delete — proceed with DB cleanup regardless + } + } + + await deps.imageHosting.delete(existing.id, orgId) + + if (existing.status === 'active' && existing.size > 0) { + await deps.storageUsage.reconcile(orgId, [existing.storageId]) + } + + return existing +} diff --git a/server/licensing/instance-info.ts b/server/usecases/instance-info.ts similarity index 70% rename from server/licensing/instance-info.ts rename to server/usecases/instance-info.ts index 8cabcfe6..8dbce581 100644 --- a/server/licensing/instance-info.ts +++ b/server/usecases/instance-info.ts @@ -1,12 +1,9 @@ import { release as osRelease } from 'node:os' -import { eq } from 'drizzle-orm' import type { InstanceInfo } from '../../shared/types' -import { systemOptions } from '../db/schema' -import type { Database, Platform } from '../platform/interface' +import type { Platform } from '../platform/interface' import { getDeployPlatform } from '../runtime-platform' -import type { CloudInstanceInfo } from '../services/licensing-cloud' import { getAppCommit, getAppVersion } from '../version' -import { getOrCreateInstanceId } from './instance-id' +import type { CloudInstanceInfo, InstanceRepo } from './ports' type RuntimeInfo = Pick @@ -22,24 +19,14 @@ export function runtimeInfo(platform: Platform): RuntimeInfo { } } -export async function getInstanceDisplayName(db: Database): Promise { - const rows = await db - .select({ value: systemOptions.value }) - .from(systemOptions) - .where(eq(systemOptions.key, 'site_title')) - .limit(1) - - return rows[0]?.value ?? 'ZPan' -} - // Shown on the admin About page: flat runtime engine + deployment platform. export async function buildInstanceInfo( - db: Database, + deps: { instance: InstanceRepo }, params: { url: string; runtime?: RuntimeInfo }, ): Promise { return { - id: await getOrCreateInstanceId(db), - name: await getInstanceDisplayName(db), + id: await deps.instance.getOrCreateInstanceId(), + name: await deps.instance.getInstanceDisplayName(), url: params.url, version: getAppVersion(), commit: getAppCommit(), @@ -63,12 +50,12 @@ function toCloudRuntime(info?: RuntimeInfo): Pick { return { - id: await getOrCreateInstanceId(db), - name: await getInstanceDisplayName(db), + id: await deps.instance.getOrCreateInstanceId(), + name: await deps.instance.getInstanceDisplayName(), url: params.url, version: getAppVersion(), commit: getAppCommit(), diff --git a/server/services/instance-telemetry.test.ts b/server/usecases/instance-telemetry.test.ts similarity index 75% rename from server/services/instance-telemetry.test.ts rename to server/usecases/instance-telemetry.test.ts index d5e1b198..335f79d1 100644 --- a/server/services/instance-telemetry.test.ts +++ b/server/usecases/instance-telemetry.test.ts @@ -1,14 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getOrCreateInstanceId } from '../licensing/instance-id' -import { getInstanceDisplayName } from '../licensing/instance-info' -import type { Database } from '../platform/interface' import { INSTANCE_TELEMETRY_CRON, INSTANCE_TELEMETRY_EVENT, INSTANCE_TELEMETRY_POSTHOG_HOST, INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, + type InstanceTelemetryDeps, reportInstanceTelemetry, } from './instance-telemetry' +import type { InstanceRepo, SystemOptionsRepo } from './ports' const posthogMocks = vi.hoisted(() => { const captureImmediateMock = vi.fn() @@ -22,33 +21,37 @@ const posthogMocks = vi.hoisted(() => { return { PostHog, captureImmediate: captureImmediateMock, shutdown: shutdownMock } }) -vi.mock('../licensing/instance-id', () => ({ - getOrCreateInstanceId: vi.fn(), -})) - -vi.mock('../licensing/instance-info', () => ({ - getInstanceDisplayName: vi.fn(), -})) - vi.mock('posthog-node', () => ({ PostHog: posthogMocks.PostHog, })) +const getOrCreateInstanceId = vi.fn() +const getInstanceDisplayName = vi.fn() +const getValue = vi.fn() + +function makeDeps(): InstanceTelemetryDeps { + return { + instance: { getOrCreateInstanceId, getInstanceDisplayName } as unknown as InstanceRepo, + systemOptions: { getValue } as unknown as SystemOptionsRepo, + } +} + describe('instance telemetry', () => { beforeEach(() => { - vi.mocked(getOrCreateInstanceId).mockReset() - vi.mocked(getInstanceDisplayName).mockReset() + getOrCreateInstanceId.mockReset() + getInstanceDisplayName.mockReset() + getValue.mockReset() + getValue.mockResolvedValue(null) posthogMocks.PostHog.mockClear() posthogMocks.captureImmediate.mockReset() posthogMocks.shutdown.mockReset() posthogMocks.captureImmediate.mockResolvedValue(undefined) posthogMocks.shutdown.mockResolvedValue(undefined) - vi.mocked(getInstanceDisplayName).mockResolvedValue('Test Instance') + getInstanceDisplayName.mockResolvedValue('Test Instance') }) it('does not call the telemetry endpoint when PostHog project token is disabled', async () => { - const result = await reportInstanceTelemetry({ - db: {} as Database, + const result = await reportInstanceTelemetry(makeDeps(), { config: { posthogProjectToken: '' }, cron: INSTANCE_TELEMETRY_CRON, runtime: { runtime: 'workerd', platform: 'cloudflare-workers' }, @@ -61,10 +64,9 @@ describe('instance telemetry', () => { }) it('captures the expected telemetry event with built-in PostHog host and project token', async () => { - vi.mocked(getOrCreateInstanceId).mockResolvedValue('inst-1') + getOrCreateInstanceId.mockResolvedValue('inst-1') - const result = await reportInstanceTelemetry({ - db: {} as Database, + const result = await reportInstanceTelemetry(makeDeps(), { config: { siteUrl: 'https://zpan.example.com/path', }, @@ -81,8 +83,8 @@ describe('instance telemetry', () => { }) expect(result).toEqual({ reported: true }) - expect(getOrCreateInstanceId).toHaveBeenCalledWith({}) - expect(getInstanceDisplayName).toHaveBeenCalledWith({}) + expect(getOrCreateInstanceId).toHaveBeenCalled() + expect(getInstanceDisplayName).toHaveBeenCalled() expect(posthogMocks.PostHog).toHaveBeenCalledWith(INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN, { host: INSTANCE_TELEMETRY_POSTHOG_HOST, flushAt: 1, @@ -168,10 +170,9 @@ describe('instance telemetry', () => { }) it('disables GeoIP when IP reporting is explicitly disabled', async () => { - vi.mocked(getOrCreateInstanceId).mockResolvedValue('inst-1') + getOrCreateInstanceId.mockResolvedValue('inst-1') - await reportInstanceTelemetry({ - db: {} as Database, + await reportInstanceTelemetry(makeDeps(), { config: { siteUrl: 'https://zpan.example.com', allowIp: false, @@ -197,4 +198,25 @@ describe('instance telemetry', () => { }), ) }) + + it('falls back to the stored site public origin when no siteUrl is configured', async () => { + getOrCreateInstanceId.mockResolvedValue('inst-1') + getValue.mockResolvedValue('https://stored.example.com') + + await reportInstanceTelemetry(makeDeps(), { + config: {}, + cron: INSTANCE_TELEMETRY_CRON, + runtime: { runtime: 'workerd', platform: 'cloudflare-workers' }, + now: new Date('2026-06-08T12:00:00.000Z'), + }) + + expect(getValue).toHaveBeenCalled() + expect(posthogMocks.captureImmediate).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + $current_url: 'https://stored.example.com', + }), + }), + ) + }) }) diff --git a/server/services/instance-telemetry.ts b/server/usecases/instance-telemetry.ts similarity index 84% rename from server/services/instance-telemetry.ts rename to server/usecases/instance-telemetry.ts index e6d22e45..d0b3d8e7 100644 --- a/server/services/instance-telemetry.ts +++ b/server/usecases/instance-telemetry.ts @@ -1,10 +1,8 @@ import { PostHog } from 'posthog-node' -import { getOrCreateInstanceId } from '../licensing/instance-id' -import { getInstanceDisplayName } from '../licensing/instance-info' -import type { Database } from '../platform/interface' +import { normalizePublicOrigin, SITE_PUBLIC_ORIGIN_KEY } from '../domain/site-public-origin' import type { DeployPlatform } from '../runtime-platform' import { getAppVersion } from '../version' -import { getSitePublicOrigin, normalizePublicOrigin } from './site-public-origin' +import type { InstanceRepo, SystemOptionsRepo } from './ports' export const INSTANCE_TELEMETRY_CRON = '0 */12 * * *' export const INSTANCE_TELEMETRY_EVENT = 'heartbeat' @@ -12,6 +10,8 @@ export const INSTANCE_TELEMETRY_INTERVAL = '12h' export const INSTANCE_TELEMETRY_POSTHOG_HOST = 'https://e.zpan.space' export const INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN = 'phc_uh9AB5AqnpXpFfW2Ns7bDGHaofSTLcA7TeatP6HzmtpF' +export type InstanceTelemetryDeps = { instance: InstanceRepo; systemOptions: SystemOptionsRepo } + export interface InstanceTelemetryConfig { posthogHost?: string posthogProjectToken?: string @@ -29,7 +29,6 @@ export interface InstanceTelemetryRuntime { } export interface InstanceTelemetryParams { - db: Database config: InstanceTelemetryConfig cron: string trigger?: 'deploy' | 'scheduled' | 'runtime' @@ -42,15 +41,17 @@ export interface InstanceTelemetryResult { reason?: 'disabled' } -export async function reportInstanceTelemetry(params: InstanceTelemetryParams): Promise { +export async function reportInstanceTelemetry( + deps: InstanceTelemetryDeps, + params: InstanceTelemetryParams, +): Promise { const posthogHost = (params.config.posthogHost ?? INSTANCE_TELEMETRY_POSTHOG_HOST).trim() const posthogProjectToken = (params.config.posthogProjectToken ?? INSTANCE_TELEMETRY_POSTHOG_PROJECT_TOKEN).trim() if (!posthogHost || !posthogProjectToken) return { reported: false, reason: 'disabled' } - const instanceId = await getOrCreateInstanceId(params.db) - const instanceName = await getInstanceDisplayName(params.db) - const instanceUrl = - normalizePublicOrigin(params.config.siteUrl) ?? (await getSitePublicOrigin(params.db)) ?? undefined + const instanceId = await deps.instance.getOrCreateInstanceId() + const instanceName = await deps.instance.getInstanceDisplayName() + const instanceUrl = normalizePublicOrigin(params.config.siteUrl) ?? (await resolveSitePublicOrigin(deps)) ?? undefined const appVersion = getAppVersion() const timestamp = (params.now ?? new Date()).toISOString() const disableGeoip = params.config.allowIp === false @@ -83,6 +84,10 @@ export async function reportInstanceTelemetry(params: InstanceTelemetryParams): return { reported: true } } +async function resolveSitePublicOrigin(deps: InstanceTelemetryDeps): Promise { + return normalizePublicOrigin(await deps.systemOptions.getValue(SITE_PUBLIC_ORIGIN_KEY)) +} + function buildTelemetryProperties(params: { instanceId: string instanceName: string diff --git a/server/licensing/verify.test.ts b/server/usecases/license-certificate.test.ts similarity index 97% rename from server/licensing/verify.test.ts rename to server/usecases/license-certificate.test.ts index 4e40bdc2..f2825f81 100644 --- a/server/licensing/verify.test.ts +++ b/server/usecases/license-certificate.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import { generateKeys, sign } from 'paseto-ts/v4' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { PUBLIC_KEYS } from './public-keys' -import { verifyCertificate, verifyCertificateResult } from './verify' +import { PUBLIC_KEYS } from '../domain/license-keys' +import { verifyCertificate, verifyCertificateResult } from './license-certificate' const { secretKey: TEST_SECRET, publicKey: TEST_PUBLIC } = generateKeys('public') const originalKeys: string[] = [] diff --git a/server/licensing/verify.ts b/server/usecases/license-certificate.ts similarity index 64% rename from server/licensing/verify.ts rename to server/usecases/license-certificate.ts index 278dbf63..36b8f8cf 100644 --- a/server/licensing/verify.ts +++ b/server/usecases/license-certificate.ts @@ -1,7 +1,8 @@ import { ZPAN_CLOUD_URL_DEFAULT } from '@shared/constants' import type { LicenseAssertion } from '@shared/types' import { verify } from 'paseto-ts/v4' -import { getTrustedPublicKeys } from './public-keys' +import { z } from 'zod' +import { getTrustedPublicKeys } from '../domain/license-keys' export interface VerifyCertificateOptions { instanceId: string @@ -108,3 +109,65 @@ function tryVerify(cert: string, publicKey: string, options: VerifyCertificateOp return { ok: true, assertion: { ...payload, authorizedHosts } } } + +const CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS = 5 * 60 + +const cloudEventTokenSchema = z.object({ + type: z.literal('commerce.fulfillment.token'), + purpose: z.literal('store.delivery'), + issuer: z.string().min(1), + audience: z.string().min(1), + boundLicenseId: z.string().min(1), + eventId: z.string().min(1), + payloadHash: z + .string() + .regex(/^[0-9a-f]{64}$/i) + .optional(), + issuedAt: z.number().int(), + notBefore: z.number().int().optional(), + expiresAt: z.number().int(), +}) + +export type CloudEventToken = z.infer + +export interface VerifyCloudEventTokenOptions { + cloudBaseUrl: string + instanceId: string + boundLicenseId: string + payloadHash: string +} + +export function verifyCloudEventToken(token: string, options: VerifyCloudEventTokenOptions): CloudEventToken | null { + for (const key of getTrustedPublicKeys()) { + const event = tryVerifyCloudEventToken(token, key, options) + if (event) return event + } + return null +} + +function tryVerifyCloudEventToken( + token: string, + publicKey: string, + options: VerifyCloudEventTokenOptions, +): CloudEventToken | null { + try { + const { payload } = verify>(publicKey, token, { validatePayload: false }) + const parsed = cloudEventTokenSchema.safeParse(payload) + if (!parsed.success) return null + + const event = parsed.data + const now = Math.floor(Date.now() / 1000) + if (event.issuer !== trustedIssuerFromCloudUrl(options.cloudBaseUrl)) return null + if (event.audience !== options.instanceId && event.audience !== options.boundLicenseId) return null + if (event.boundLicenseId !== options.boundLicenseId) return null + if (event.payloadHash && event.payloadHash !== options.payloadHash) return null + if (event.issuedAt > now) return null + if (event.notBefore && event.notBefore > now) return null + if (event.expiresAt <= now) return null + if (event.expiresAt - event.issuedAt > CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS) return null + + return event + } catch { + return null + } +} diff --git a/server/licensing/entitlement.test.ts b/server/usecases/license-entitlement.test.ts similarity index 82% rename from server/licensing/entitlement.test.ts rename to server/usecases/license-entitlement.test.ts index 29108129..04a812aa 100644 --- a/server/licensing/entitlement.test.ts +++ b/server/usecases/license-entitlement.test.ts @@ -3,12 +3,12 @@ import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' import { generateKeys, sign } from 'paseto-ts/v4' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLicenseBindingRepo } from '../adapters/repos/license-binding' import * as authSchema from '../db/auth-schema' import * as appSchema from '../db/schema' -import { invalidateEntitlementCache, loadEntitlement } from './entitlement' -import { effectiveFeatures } from './has-feature' -import { createLicenseBinding } from './license-state' -import { PUBLIC_KEYS } from './public-keys' +import { PUBLIC_KEYS } from '../domain/license-keys' +import { effectiveFeatures } from '../domain/licensing' +import { invalidateEntitlementCache, loadEntitlement } from './license-entitlement' const SCHEMA_SQL = ` CREATE TABLE IF NOT EXISTS license_bindings ( @@ -81,7 +81,7 @@ function signAssertion(overrides: Record = {}): string { async function seedBinding(db: DB, cachedCert: string | null) { const now = nowSec() - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'bind-1', instanceId: 'inst-1', cloudAccountId: 'acct-1', @@ -107,7 +107,7 @@ describe('loadEntitlement', () => { it('returns null when no binding exists', async () => { const db = makeDb() - const result = await loadEntitlement(db) + const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(result).toBeNull() }) @@ -115,7 +115,7 @@ describe('loadEntitlement', () => { const db = makeDb() await seedBinding(db, null) - const result = await loadEntitlement(db) + const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(result).toBeNull() }) @@ -133,7 +133,7 @@ describe('loadEntitlement', () => { }), ) - const result = await loadEntitlement(db) + const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(result).not.toBeNull() expect(result?.edition).toBe('pro') expect(result?.features).toEqual(effectiveFeatures('pro')) @@ -153,7 +153,7 @@ describe('loadEntitlement', () => { }), ) - const result = await loadEntitlement(db) + const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(result).toMatchObject({ edition: 'business', features: effectiveFeatures('business'), @@ -167,12 +167,12 @@ describe('loadEntitlement', () => { const db = makeDb() await seedBinding(db, signAssertion({ expiresAt: nowSec() + 30 })) - const first = await loadEntitlement(db) + const first = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(first?.edition).toBe('pro') // Advance past the cert's 30s expiry but within the 60s cache TTL. vi.advanceTimersByTime(40_000) - const second = await loadEntitlement(db) + const second = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(second).toBeNull() } finally { vi.useRealTimers() @@ -187,7 +187,7 @@ describe('loadEntitlement', () => { signAssertion({ issuedAt: nowSec() - 100, notBefore: nowSec() - 100, expiresAt: nowSec() - 1 }), ) - const result = await loadEntitlement(db) + const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(result).toBeNull() }) }) @@ -196,13 +196,13 @@ describe('invalidateEntitlementCache', () => { it('clears cached state so next call re-reads from DB', async () => { const db = makeDb() - await loadEntitlement(db) + await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) await seedBinding(db, signAssertion()) invalidateEntitlementCache() - const result = await loadEntitlement(db) + const result = await loadEntitlement({ licenseBinding: createLicenseBindingRepo(db) }) expect(result?.edition).toBe('pro') }) }) diff --git a/server/licensing/entitlement.ts b/server/usecases/license-entitlement.ts similarity index 81% rename from server/licensing/entitlement.ts rename to server/usecases/license-entitlement.ts index a51c9207..3ccb8461 100644 --- a/server/licensing/entitlement.ts +++ b/server/usecases/license-entitlement.ts @@ -1,8 +1,7 @@ import type { LicenseFeature } from '@shared/types' -import type { Database } from '../platform/interface' -import { effectiveFeatures } from './has-feature' -import { loadLicenseState } from './license-state' -import { verifyCertificate } from './verify' +import { effectiveFeatures } from '../domain/licensing' +import { verifyCertificate } from './license-certificate' +import type { LicenseBindingRepo } from './ports' export interface EntitlementSummary { edition: 'pro' | 'business' @@ -17,7 +16,9 @@ const CACHE_TTL_MS = 60_000 let cachedSummary: EntitlementSummary | null = null let cachedAt = 0 -export async function loadEntitlement(db: Database): Promise { +export async function loadEntitlement(deps: { + licenseBinding: LicenseBindingRepo +}): Promise { const now = Date.now() if (cachedAt > 0 && now - cachedAt < CACHE_TTL_MS) { // The 60s TTL must not outlive the certificate itself: a cert that expires @@ -30,7 +31,7 @@ export async function loadEntitlement(db: Database): Promise = {}): string { }) } -async function seedBinding( - db: DB, - overrides: Partial[1]> & { lastRefreshError?: string } = {}, -) { +async function seedBinding(db: DB, overrides: Partial & { lastRefreshError?: string } = {}) { const now = nowSec() const lastRefreshError = overrides.lastRefreshError - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'bind-1', cloudStoreId: 'store-old', instanceId: 'inst-abc', @@ -113,7 +112,12 @@ describe('performRefresh', () => { it('is a no-op when no binding exists', async () => { const db = makeDb() - await expect(performRefresh(db, 'https://cloud.zpan.space')).resolves.toBeUndefined() + await expect( + performRefresh( + { licensingCloud: createLicensingCloudGateway(), licenseBinding: createLicenseBindingRepo(db) }, + 'https://cloud.zpan.space', + ), + ).resolves.toBeUndefined() }) it('rotates refreshToken and stores PASETO certificate from cloud', async () => { @@ -135,9 +139,12 @@ describe('performRefresh', () => { text: async () => '', } as unknown as Response) - await performRefresh(db, 'https://cloud.zpan.space') + await performRefresh( + { licensingCloud: createLicensingCloudGateway(), licenseBinding: createLicenseBindingRepo(db) }, + 'https://cloud.zpan.space', + ) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBe('new-rt') expect(state.cloudStoreId).toBe('store-new') expect(state.cachedCert).toBe(cert) @@ -165,9 +172,12 @@ describe('performRefresh', () => { text: async () => '', } as unknown as Response) - await performRefresh(db, 'https://cloud.zpan.space') + await performRefresh( + { licensingCloud: createLicensingCloudGateway(), licenseBinding: createLicenseBindingRepo(db) }, + 'https://cloud.zpan.space', + ) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBe('new-rt-paseto') expect(state.cachedCert).toBe(cert) expect(state.cachedExpiresAt).toBe(expiresAt) @@ -184,9 +194,12 @@ describe('performRefresh', () => { text: async () => '', } as unknown as Response) - await performRefresh(db, 'https://cloud.zpan.space') + await performRefresh( + { licensingCloud: createLicensingCloudGateway(), licenseBinding: createLicenseBindingRepo(db) }, + 'https://cloud.zpan.space', + ) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBeNull() }) @@ -196,9 +209,12 @@ describe('performRefresh', () => { vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection timeout')) - await performRefresh(db, 'https://cloud.zpan.space') + await performRefresh( + { licensingCloud: createLicensingCloudGateway(), licenseBinding: createLicenseBindingRepo(db) }, + 'https://cloud.zpan.space', + ) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBe('old-rt') expect(state.lastRefreshError).toBe('Connection timeout') }) @@ -224,9 +240,12 @@ describe('performRefresh', () => { text: async () => '', } as unknown as Response) - await performRefresh(db, 'https://cloud.zpan.space') + await performRefresh( + { licensingCloud: createLicensingCloudGateway(), licenseBinding: createLicenseBindingRepo(db) }, + 'https://cloud.zpan.space', + ) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBe('old-rt') expect(state.cachedCert).toBe('old-cert') expect(state.cachedExpiresAt).toBe(1234567890) @@ -248,9 +267,12 @@ describe('performRefresh', () => { text: async () => '', } as unknown as Response) - await performRefresh(db, 'https://cloud.zpan.space') + await performRefresh( + { licensingCloud: createLicensingCloudGateway(), licenseBinding: createLicenseBindingRepo(db) }, + 'https://cloud.zpan.space', + ) - const state = await loadLicenseState(db) + const state = await createLicenseBindingRepo(db).loadLicenseState() expect(state.refreshToken).toBe('old-rt') expect(state.lastRefreshError).toBe('Cloud response missing certificate') }) diff --git a/server/licensing/refresh.ts b/server/usecases/license-refresh.ts similarity index 61% rename from server/licensing/refresh.ts rename to server/usecases/license-refresh.ts index 6f570836..e5a3d79f 100644 --- a/server/licensing/refresh.ts +++ b/server/usecases/license-refresh.ts @@ -1,19 +1,13 @@ -import type { Database } from '../platform/interface' +import { verifyCertificate } from './license-certificate' +import { invalidateEntitlementCache } from './license-entitlement' import { type CloudInstanceInfo, CloudInvalidResponseError, CloudNetworkError, CloudUnboundError, - refreshEntitlement, -} from '../services/licensing-cloud' -import { invalidateEntitlementCache } from './entitlement' -import { - clearLicenseBinding, - loadLicenseState, - setLicenseRefreshError, - updateLicenseBindingAfterRefresh, -} from './license-state' -import { verifyCertificate } from './verify' + type LicenseBindingRepo, + type LicensingCloudGateway, +} from './ports' const INVALID_CERTIFICATE_ERROR = 'Invalid certificate from cloud' const INVALID_ENTITLEMENT_RESPONSE_ERROR = 'Invalid entitlement response from cloud' @@ -26,26 +20,30 @@ function normaliseCert( return { cert: raw, certificateExpiresAt: assertion?.expiresAt ?? null } } -export async function performRefresh(db: Database, baseUrl: string, instance?: CloudInstanceInfo): Promise { - const state = await loadLicenseState(db) +export async function performRefresh( + deps: { licensingCloud: LicensingCloudGateway; licenseBinding: LicenseBindingRepo }, + baseUrl: string, + instance?: CloudInstanceInfo, +): Promise { + const state = await deps.licenseBinding.loadLicenseState() if (!state.refreshToken || !state.instanceId) return try { - const data = await refreshEntitlement(baseUrl, state.refreshToken, instance) + const data = await deps.licensingCloud.refreshEntitlement(baseUrl, state.refreshToken, instance) const { cert, certificateExpiresAt } = normaliseCert(data.certificate, { instanceId: state.instanceId, cloudBaseUrl: baseUrl, }) if (!certificateExpiresAt) { - await setLicenseRefreshError(db, state.id, INVALID_CERTIFICATE_ERROR) + await deps.licenseBinding.setLicenseRefreshError(state.id, INVALID_CERTIFICATE_ERROR) return } if (!data.binding?.storeId || !data.account) { - await setLicenseRefreshError(db, state.id, INVALID_ENTITLEMENT_RESPONSE_ERROR) + await deps.licenseBinding.setLicenseRefreshError(state.id, INVALID_ENTITLEMENT_RESPONSE_ERROR) return } - await updateLicenseBindingAfterRefresh(db, { + await deps.licenseBinding.updateLicenseBindingAfterRefresh({ id: state.id, refreshToken: data.refreshToken, cloudStoreId: data.binding.storeId, @@ -58,13 +56,13 @@ export async function performRefresh(db: Database, baseUrl: string, instance?: C invalidateEntitlementCache() } catch (err) { if (err instanceof CloudUnboundError) { - await clearLicenseBinding(db, 'revoked') + await deps.licenseBinding.clearLicenseBinding('revoked') invalidateEntitlementCache() return } if (err instanceof CloudInvalidResponseError || err instanceof CloudNetworkError || err instanceof Error) { - await setLicenseRefreshError(db, state.id, err.message) + await deps.licenseBinding.setLicenseRefreshError(state.id, err.message) return } diff --git a/server/services/licensing-refresh-runner.test.ts b/server/usecases/licensing-refresh-runner.test.ts similarity index 78% rename from server/services/licensing-refresh-runner.test.ts rename to server/usecases/licensing-refresh-runner.test.ts index eed3d0d0..88afe1a6 100644 --- a/server/services/licensing-refresh-runner.test.ts +++ b/server/usecases/licensing-refresh-runner.test.ts @@ -1,18 +1,23 @@ import { eq } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLicensingCloudGateway } from '../adapters/gateways/licensing-cloud.js' +import { createLicenseBindingRepo } from '../adapters/repos/license-binding.js' import { licenseBindings } from '../db/schema.js' -import { createLicenseBinding } from '../licensing/license-state.js' -import * as refreshModule from '../licensing/refresh.js' import { createTestApp } from '../test/setup.js' +import * as refreshModule from './license-refresh.js' import { runLicensingRefresh } from './licensing-refresh-runner.js' const CLOUD_URL = 'https://cloud.zpan.space' +function makeDeps(db: Awaited>['db']) { + return { licenseBinding: createLicenseBindingRepo(db), licensingCloud: createLicensingCloudGateway() } +} + async function seedLicenseBinding( db: Awaited>['db'], overrides: { lastRefreshAt?: number | null } = {}, ) { - await createLicenseBinding(db, { + await createLicenseBindingRepo(db).createLicenseBinding({ cloudBindingId: 'bind-1', cloudStoreId: 'store-1', instanceId: 'inst-1', @@ -41,7 +46,7 @@ describe('runLicensingRefresh', () => { it('returns immediately with no-op when no license binding exists', async () => { const { db } = await createTestApp() - await expect(runLicensingRefresh(db, CLOUD_URL)).resolves.toBeUndefined() + await expect(runLicensingRefresh(makeDeps(db), CLOUD_URL)).resolves.toBeUndefined() expect(performRefreshSpy).not.toHaveBeenCalled() }) @@ -51,7 +56,7 @@ describe('runLicensingRefresh', () => { const recentRefresh = Math.floor(Date.now() / 1000) - 120 await seedLicenseBinding(db, { lastRefreshAt: recentRefresh }) - await runLicensingRefresh(db, CLOUD_URL) + await runLicensingRefresh(makeDeps(db), CLOUD_URL) expect(performRefreshSpy).not.toHaveBeenCalled() }) @@ -64,10 +69,11 @@ describe('runLicensingRefresh', () => { performRefreshSpy.mockResolvedValueOnce(undefined) - await runLicensingRefresh(db, CLOUD_URL) + const deps = makeDeps(db) + await runLicensingRefresh(deps, CLOUD_URL) expect(performRefreshSpy).toHaveBeenCalledOnce() - expect(performRefreshSpy).toHaveBeenCalledWith(db, CLOUD_URL) + expect(performRefreshSpy).toHaveBeenCalledWith(deps, CLOUD_URL) }) it('calls performRefresh when lastRefreshAt is null', async () => { @@ -77,7 +83,7 @@ describe('runLicensingRefresh', () => { performRefreshSpy.mockResolvedValueOnce(undefined) - await runLicensingRefresh(db, CLOUD_URL) + await runLicensingRefresh(makeDeps(db), CLOUD_URL) expect(performRefreshSpy).toHaveBeenCalledOnce() }) @@ -90,7 +96,7 @@ describe('runLicensingRefresh', () => { performRefreshSpy.mockResolvedValueOnce(undefined) - await runLicensingRefresh(db, CLOUD_URL) + await runLicensingRefresh(makeDeps(db), CLOUD_URL) expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.ok') }) @@ -103,7 +109,7 @@ describe('runLicensingRefresh', () => { performRefreshSpy.mockRejectedValueOnce(new Error('network timeout')) - await runLicensingRefresh(db, CLOUD_URL) + await runLicensingRefresh(makeDeps(db), CLOUD_URL) expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.error code=network timeout') }) @@ -116,7 +122,7 @@ describe('runLicensingRefresh', () => { performRefreshSpy.mockRejectedValueOnce('plain string error') - await runLicensingRefresh(db, CLOUD_URL) + await runLicensingRefresh(makeDeps(db), CLOUD_URL) expect(consoleSpy).toHaveBeenCalledWith('licensing.refresh.error code=plain string error') }) @@ -129,6 +135,6 @@ describe('runLicensingRefresh', () => { performRefreshSpy.mockRejectedValueOnce(new Error('unexpected')) - await expect(runLicensingRefresh(db, CLOUD_URL)).resolves.toBeUndefined() + await expect(runLicensingRefresh(makeDeps(db), CLOUD_URL)).resolves.toBeUndefined() }) }) diff --git a/server/services/licensing-refresh-runner.ts b/server/usecases/licensing-refresh-runner.ts similarity index 56% rename from server/services/licensing-refresh-runner.ts rename to server/usecases/licensing-refresh-runner.ts index 7b2e2bec..2136265a 100644 --- a/server/services/licensing-refresh-runner.ts +++ b/server/usecases/licensing-refresh-runner.ts @@ -1,16 +1,16 @@ -import { loadLicenseState } from '../licensing/license-state' -import { performRefresh } from '../licensing/refresh' -import type { Database } from '../platform/interface' -import type { CloudInstanceInfo } from './licensing-cloud' +import { performRefresh } from './license-refresh' +import type { CloudInstanceInfo, LicenseBindingRepo, LicensingCloudGateway } from './ports' const DEDUP_WINDOW_SEC = 5 * 60 +export type LicensingRefreshDeps = { licenseBinding: LicenseBindingRepo; licensingCloud: LicensingCloudGateway } + export async function runLicensingRefresh( - db: Database, + deps: LicensingRefreshDeps, cloudBaseUrl: string, instance?: CloudInstanceInfo, ): Promise { - const state = await loadLicenseState(db) + const state = await deps.licenseBinding.loadLicenseState() if (!state.refreshToken) return // unbound — no-op const nowSec = Math.floor(Date.now() / 1000) @@ -18,9 +18,9 @@ export async function runLicensingRefresh( try { if (instance) { - await performRefresh(db, cloudBaseUrl, instance) + await performRefresh(deps, cloudBaseUrl, instance) } else { - await performRefresh(db, cloudBaseUrl) + await performRefresh(deps, cloudBaseUrl) } console.log('licensing.refresh.ok') } catch (err) { diff --git a/server/usecases/licensing.ts b/server/usecases/licensing.ts new file mode 100644 index 00000000..376a623b --- /dev/null +++ b/server/usecases/licensing.ts @@ -0,0 +1,45 @@ +import type { BindingState } from '@shared/types' +import { effectiveFeatures } from '../domain/licensing' +import { verifyCertificate } from './license-certificate' +import type { LicenseBindingRepo } from './ports' + +export interface BindingStateOptions { + currentHost?: string | null + cloudBaseUrl?: string | null +} + +// Reads the active license binding and derives the runtime BindingState by +// verifying the cached certificate. Orchestration: repo read + pure verify. +export async function loadBindingState( + deps: { licenseBinding: LicenseBindingRepo }, + options: BindingStateOptions = {}, +): Promise { + const state = await deps.licenseBinding.loadLicenseState() + if (!state.refreshToken) return { bound: false } + + const result: BindingState = { + bound: true, + active: false, + account_email: state.cloudAccountEmail ?? undefined, + last_refresh_at: state.lastRefreshAt ?? undefined, + last_refresh_error: state.lastRefreshError ?? undefined, + } + + if (state.cachedCert && state.instanceId) { + const assertion = verifyCertificate(state.cachedCert, { + instanceId: state.instanceId, + currentHost: options.currentHost, + cloudBaseUrl: options.cloudBaseUrl, + }) + if (assertion) { + result.active = true + result.edition = assertion.edition + result.features = effectiveFeatures(assertion.edition) + result.license_id = assertion.licenseId + result.license_valid_until = assertion.licenseValidUntil + result.certificate_expires_at = assertion.expiresAt + } + } + + return result +} diff --git a/server/usecases/matter.ts b/server/usecases/matter.ts new file mode 100644 index 00000000..dc9b4d1b --- /dev/null +++ b/server/usecases/matter.ts @@ -0,0 +1,108 @@ +import type { ActivityRepo, ConflictStrategy, Matter, MatterRepo, QuotaRepo, StorageUsageRepo } from './ports' +import { StorageQuotaExceededError, withStorageUsageReservation } from './storage-usage' + +// Quota-guarded draft→active confirmation. Composes the matter repo (conflict +// plan + draft activation) with the storage-usage reservation usecase, reaching +// the DB only through deps. Behavior preserved from the former matter service. +export type ConfirmUploadDeps = { + matter: MatterRepo + quota: QuotaRepo + storageUsage: StorageUsageRepo + activity: ActivityRepo +} + +export interface ConfirmUploadOptions { + onConflict?: ConflictStrategy + userId?: string + teamQuotaEnabled?: boolean + /** + * Overwrites the file being replaced: hard-purge it (delete row, S3 object, + * shares). With it, a 'replace' frees the incumbent's quota so the upload is + * charged as a net-size change — matching normal overwrite semantics. Without + * it, replace falls back to trashing the incumbent. + */ + purgeReplaced?: (incumbent: Matter) => Promise +} + +export async function confirmUpload( + deps: ConfirmUploadDeps, + id: string, + orgId: string, + opts: ConfirmUploadOptions = {}, +): Promise<{ matter: Matter | null; quotaExceeded?: boolean }> { + try { + const existing = await deps.matter.get(id, orgId) + if (!existing) return { matter: null } + if (existing.status !== 'draft') return { matter: null } + + // Plan the overwrite now (side-effect-free). createMatter deferred it for + // draft 'replace', so the incumbent is still active and the quota check + // below accounts for its bytes being freed. The DB's partial unique index + // fires on the status update as a final safety net against concurrent confirms. + const plan = await deps.matter.planConflictResolution( + orgId, + existing.parent, + existing.name, + opts.onConflict ?? 'fail', + { excludeId: existing.id, isFolder: false, userId: opts.userId }, + ) + + const bytes = existing.size ?? 0 + // Purging the incumbent frees its bytes, so only the net size increase needs + // headroom; a final reconcile then sets usage to the exact active+trashed sum. + const overwrites = plan.toTrash != null && opts.purgeReplaced != null + const reserveBytes = overwrites ? Math.max(0, bytes - (plan.toTrash?.size ?? 0)) : bytes + + return await withStorageUsageReservation( + { quota: deps.quota, storageUsage: deps.storageUsage }, + { orgId, storageId: existing.storageId, bytes: reserveBytes, teamQuotaEnabled: opts.teamQuotaEnabled ?? true }, + async () => { + // Quota reserved — now safe to execute the overwrite (if any). + if (plan.toTrash && opts.purgeReplaced) { + await opts.purgeReplaced(plan.toTrash) + if (opts.userId) { + await deps.activity.record({ + orgId, + userId: opts.userId, + action: 'replace', + targetType: 'file', + targetId: plan.toTrash.id, + targetName: plan.toTrash.name, + }) + } + } else { + await deps.matter.commitConflictPlan(orgId, plan, opts.userId) + } + + const now = new Date() + const activated = await deps.matter.activateDraft(id, orgId, plan.finalName, now) + if (!activated) { + throw new Error('CONFIRM_UPLOAD_RACE') + } + + // The purge reconciled usage before this row became active; recompute + // once more so the new file's bytes are reflected. + if (overwrites) await deps.storageUsage.reconcile(orgId, [existing.storageId]) + + const confirmed = { ...existing, name: plan.finalName, status: 'active', updatedAt: now } + + if (opts.userId) { + await deps.activity.record({ + orgId, + userId: opts.userId, + action: 'upload_confirm', + targetType: 'file', + targetId: confirmed.id, + targetName: confirmed.name, + }) + } + + return { matter: confirmed } + }, + ) + } catch (error) { + if (error instanceof StorageQuotaExceededError) return { matter: null, quotaExceeded: true } + if (error instanceof Error && error.message === 'CONFIRM_UPLOAD_RACE') return { matter: null } + throw error + } +} diff --git a/server/usecases/object-upload-session.ts b/server/usecases/object-upload-session.ts new file mode 100644 index 00000000..ce1fb551 --- /dev/null +++ b/server/usecases/object-upload-session.ts @@ -0,0 +1,133 @@ +import type { PatchObjectUploadSessionInput } from '@shared/schemas' +import type { ObjectUploadSession } from '@shared/types' +import { + ObjectUploadSessionError, + type ObjectUploadSessionRecord, + type ObjectUploadSessionRepo, + type S3Gateway, + type StorageRecord, +} from './ports' + +export type ObjectUploadSessionDeps = { s3: S3Gateway; objectUploadSessions: ObjectUploadSessionRepo } + +const DEFAULT_PART_SIZE = 16 * 1024 * 1024 + +function toDto(record: ObjectUploadSessionRecord): ObjectUploadSession { + return { + id: record.id, + objectId: record.objectId, + uploadId: record.uploadId, + partSize: record.partSize, + status: record.status, + expiresAt: record.expiresAt.toISOString(), + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + } +} + +export async function createObjectUploadSession( + deps: ObjectUploadSessionDeps, + params: { + orgId: string + objectId: string + storage: StorageRecord + storageKey: string + contentType: string + partSize?: number + actorId: string + }, +): Promise { + let uploadId: string + try { + uploadId = await deps.s3.createMultipartUpload(params.storage, params.storageKey, params.contentType) + } catch (error) { + throw new ObjectUploadSessionError( + 'storage_failure', + `Storage multipart upload failed: ${(error as Error).message}`, + ) + } + const record = await deps.objectUploadSessions.create({ + orgId: params.orgId, + objectId: params.objectId, + storageId: params.storage.id, + storageKey: params.storageKey, + uploadId, + partSize: params.partSize ?? DEFAULT_PART_SIZE, + actorId: params.actorId, + }) + return toDto(record) +} + +export async function getObjectUploadSession( + deps: ObjectUploadSessionDeps, + orgId: string, + objectId: string, + id: string, +): Promise { + const record = await deps.objectUploadSessions.get(orgId, objectId, id) + if (!record) throw new ObjectUploadSessionError('not_found') + return toDto(record) +} + +export async function presignObjectUploadParts( + deps: ObjectUploadSessionDeps, + params: { + orgId: string + objectId: string + sessionId: string + storage: StorageRecord + partNumbers: number[] + }, +): Promise<{ uploadId: string; partSize: number; parts: Array<{ partNumber: number; url: string }> }> { + const record = await deps.objectUploadSessions.get(params.orgId, params.objectId, params.sessionId) + if (!record) throw new ObjectUploadSessionError('not_found') + if (record.status !== 'active' || record.expiresAt.getTime() <= Date.now()) { + throw new ObjectUploadSessionError('invalid_state') + } + const parts = await Promise.all( + params.partNumbers.map(async (partNumber) => ({ + partNumber, + url: await deps.s3.presignUploadPart(params.storage, record.storageKey, record.uploadId, partNumber), + })), + ) + return { uploadId: record.uploadId, partSize: record.partSize, parts } +} + +export async function patchObjectUploadSession( + deps: ObjectUploadSessionDeps, + params: { + orgId: string + objectId: string + sessionId: string + storage: StorageRecord + input: PatchObjectUploadSessionInput + }, +): Promise { + const record = await deps.objectUploadSessions.get(params.orgId, params.objectId, params.sessionId) + if (!record) throw new ObjectUploadSessionError('not_found') + if (record.status !== 'active') throw new ObjectUploadSessionError('invalid_state') + if (params.input.action === 'complete') { + try { + await deps.s3.completeMultipartUpload(params.storage, record.storageKey, record.uploadId, params.input.parts) + } catch (error) { + throw new ObjectUploadSessionError( + 'storage_failure', + `Storage multipart upload complete failed: ${(error as Error).message}`, + ) + } + await deps.objectUploadSessions.setStatus(record.id, 'completed') + } else { + try { + await deps.s3.abortMultipartUpload(params.storage, record.storageKey, record.uploadId) + } catch (error) { + throw new ObjectUploadSessionError( + 'storage_failure', + `Storage multipart upload abort failed: ${(error as Error).message}`, + ) + } + await deps.objectUploadSessions.setStatus(record.id, 'aborted') + } + return getObjectUploadSession(deps, params.orgId, params.objectId, params.sessionId) +} + +export { ObjectUploadSessionError } from './ports' diff --git a/server/usecases/ports.ts b/server/usecases/ports.ts new file mode 100644 index 00000000..cd505b38 --- /dev/null +++ b/server/usecases/ports.ts @@ -0,0 +1,45 @@ +// Barrel for every port (repository / gateway / provider interface) and the +// port-level error classes the http layer maps to status codes. One re-export +// line per resource keeps `usecases/ports` the single import surface while each +// resource owns its own file under ports/. + +export * from './ports/activity' +export * from './ports/announcement' +export * from './ports/api-keys' +export * from './ports/archive-jobs' +export * from './ports/archive-target-folder' +export * from './ports/background-job' +export * from './ports/cf-hostnames' +export * from './ports/changelog' +export * from './ports/cloud-store' +export * from './ports/cloud-traffic-report' +export * from './ports/download-tokens' +export * from './ports/downloads' +export * from './ports/email' +export * from './ports/image-hosting' +export * from './ports/image-hosting-config' +export * from './ports/image-upload' +export * from './ports/invite' +export * from './ports/license-binding' +export * from './ports/licensing-cloud' +export * from './ports/matter' +export * from './ports/member-count' +export * from './ports/notification' +export * from './ports/object-upload-session' +export * from './ports/org' +export * from './ports/profile' +export * from './ports/quota' +export * from './ports/remote-download-usage' +export * from './ports/s3' +export * from './ports/share' +export * from './ports/share-notification' +export * from './ports/site-invitation' +export * from './ports/storage' +export * from './ports/storage-usage' +export * from './ports/system-options' +export * from './ports/team' +export * from './ports/team-invite' +export * from './ports/user' +export * from './ports/webdav-path' +export * from './ports/webdav-state' +export * from './ports/zip' diff --git a/server/usecases/ports/activity.ts b/server/usecases/ports/activity.ts new file mode 100644 index 00000000..e8f7144e --- /dev/null +++ b/server/usecases/ports/activity.ts @@ -0,0 +1,53 @@ +// Plain, framework-free DTOs and the repository port for activity/audit events. +// No drizzle here — the port is what usecases and http see; the adapter maps rows +// into these shapes. + +export interface RecordActivityInput { + orgId: string + userId: string + action: string + targetType: string + targetId?: string + targetName: string + metadata?: Record +} + +export interface ActivityEvent { + id: string + orgId: string + userId: string + action: string + targetType: string + targetId: string | null + targetName: string + metadata: string | null + createdAt: Date +} + +export interface ActivityEventWithUser extends ActivityEvent { + user: { id: string; name: string; image: string | null } +} + +export interface AdminAuditEventWithOrg extends ActivityEventWithUser { + orgName: string | null +} + +export interface ListAdminAuditOpts { + page?: number + pageSize?: number + orgId?: string + userId?: string + action?: string + targetType?: string +} + +export interface ActivityRepo { + record(event: RecordActivityInput): Promise + list( + orgId: string, + opts: { page?: number; pageSize?: number }, + ): Promise<{ items: ActivityEventWithUser[]; total: number }> + listAdminAudit( + opts: ListAdminAuditOpts, + ): Promise<{ items: AdminAuditEventWithOrg[]; total: number; page: number; pageSize: number }> +} diff --git a/server/usecases/ports/announcement.ts b/server/usecases/ports/announcement.ts new file mode 100644 index 00000000..b66b22a5 --- /dev/null +++ b/server/usecases/ports/announcement.ts @@ -0,0 +1,30 @@ +import type { AnnouncementInput, AnnouncementStatus } from '@shared/schemas' + +export interface AnnouncementRecord { + id: string + title: string + body: string + status: AnnouncementStatus + priority: number + publishedAt: Date | null + expiresAt: Date | null + createdBy: string + createdAt: Date + updatedAt: Date +} + +export interface ListAnnouncementsResult { + items: AnnouncementRecord[] + total: number + page: number + pageSize: number +} + +export interface AnnouncementRepo { + create(input: AnnouncementInput, createdBy: string): Promise + listAdmin(opts: { status?: AnnouncementStatus; page: number; pageSize: number }): Promise + get(id: string): Promise + update(id: string, input: AnnouncementInput): Promise + delete(id: string): Promise + listUser(opts: { activeOnly: boolean; page: number; pageSize: number }): Promise +} diff --git a/server/usecases/ports/api-keys.ts b/server/usecases/ports/api-keys.ts new file mode 100644 index 00000000..db910a49 --- /dev/null +++ b/server/usecases/ports/api-keys.ts @@ -0,0 +1,41 @@ +import type { ApiKeyPermissions } from '@shared/api-key-templates' +import type { Database } from '../../platform/interface' + +export interface VerifiedApiKey { + id: string + configId: string + referenceId: string + permissions: ApiKeyPermissions | null +} + +// Structural view of better-auth used by the gateway. Keeps the port free of the +// better-auth framework type while letting callers pass the real `Auth`. +export interface ApiKeyAuth { + api: Record +} + +// Thrown by the gateway when better-auth reports the key is rate limited. The +// http layer (business routes + WebDAV) maps it to 429 with Retry-After. +export class ApiKeyRateLimitError extends Error { + constructor( + message: string, + public readonly retryAfterMs?: number, + ) { + super(message) + this.name = 'ApiKeyRateLimitError' + } +} + +export interface ApiKeyGateway { + verifyApiKey(auth: ApiKeyAuth, db: Database, key: string, configId?: string): Promise + verifyApiKeyForPermission( + auth: ApiKeyAuth, + db: Database, + key: string, + resource: string, + action: string, + configId?: string, + ): Promise + hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource: string, action: string): boolean + isOrgApiKey(configId: string): boolean +} diff --git a/server/usecases/ports/archive-jobs.ts b/server/usecases/ports/archive-jobs.ts new file mode 100644 index 00000000..9eb38396 --- /dev/null +++ b/server/usecases/ports/archive-jobs.ts @@ -0,0 +1,16 @@ +import type { CreateBackgroundJobRequest } from '@shared/schemas' + +export interface ArchiveJobMessage { + jobId: string + orgId: string + userId: string + request: CreateBackgroundJobRequest +} + +export interface ArchiveJobsGateway { + // Hand a job off for asynchronous processing: a queue binding when present, + // otherwise an in-process worker that drains on the next tick. + dispatch(message: ArchiveJobMessage): Promise + // Process a single queued message synchronously (the queue consumer entrypoint). + runMessage(message: ArchiveJobMessage): Promise +} diff --git a/server/usecases/ports/archive-target-folder.ts b/server/usecases/ports/archive-target-folder.ts new file mode 100644 index 00000000..921454a7 --- /dev/null +++ b/server/usecases/ports/archive-target-folder.ts @@ -0,0 +1,10 @@ +// Validates that an explicit archive target folder exists and is a folder (not a +// file). Owned by its own repo so the archive-processing usecase never touches +// the matters table directly — the only genuinely-new persistence the archive +// orchestration needs beyond the matter service / zip plan repo. +export interface ArchiveTargetFolderRepo { + // Throws 'Target folder not found' when no active matter matches the path, or + // 'Target folder must be a folder' when it resolves to a file. An empty path + // (the workspace root) is always valid. + requireTargetFolder(orgId: string, targetFolder: string): Promise +} diff --git a/server/usecases/ports/background-job.ts b/server/usecases/ports/background-job.ts new file mode 100644 index 00000000..aaa32627 --- /dev/null +++ b/server/usecases/ports/background-job.ts @@ -0,0 +1,52 @@ +import type { BackgroundJob, BackgroundJobProgress, BackgroundJobStatus, BackgroundJobType } from '@shared/types' + +export type BackgroundJobMetadata = Record + +export type CreateBackgroundJobInput = { + orgId: string + userId: string + type: BackgroundJobType + targetFolder?: string | null + targetPath?: string | null + metadata?: BackgroundJobMetadata | null + progress?: Partial + retryable?: boolean + cancelable?: boolean +} + +export type ListBackgroundJobsOptions = { + status?: BackgroundJobStatus + type?: string + page: number + pageSize: number +} + +export type UpdateBackgroundJobInput = { + status?: BackgroundJobStatus + progress?: Partial + errorMessage?: string | null + resultMetadata?: BackgroundJobMetadata | null + retryable?: boolean + cancelable?: boolean + startedAt?: Date | null + finishedAt?: Date | null +} + +export class BackgroundJobError extends Error { + constructor( + readonly code: 'not_found' | 'not_cancelable' | 'not_retryable', + message = code, + ) { + super(message) + this.name = 'BackgroundJobError' + } +} + +export interface BackgroundJobRepo { + create(input: CreateBackgroundJobInput): Promise + list(orgId: string, opts: ListBackgroundJobsOptions): Promise<{ items: BackgroundJob[]; total: number }> + get(orgId: string, id: string): Promise + update(orgId: string, id: string, input: UpdateBackgroundJobInput): Promise + cancel(orgId: string, id: string): Promise + retry(orgId: string, id: string): Promise +} diff --git a/server/usecases/ports/cf-hostnames.ts b/server/usecases/ports/cf-hostnames.ts new file mode 100644 index 00000000..5ccb3a92 --- /dev/null +++ b/server/usecases/ports/cf-hostnames.ts @@ -0,0 +1,12 @@ +export interface CfHostnameStatus { + status: 'pending' | 'active' | 'moved' | 'deleted' | 'blocked' + ssl_status: string +} + +export class CfConflictError extends Error {} + +export interface CfHostnamesProvider { + register(hostname: string): Promise<{ id: string }> + getStatus(id: string): Promise + delete(id: string): Promise +} diff --git a/server/usecases/ports/changelog.ts b/server/usecases/ports/changelog.ts new file mode 100644 index 00000000..84594b98 --- /dev/null +++ b/server/usecases/ports/changelog.ts @@ -0,0 +1,11 @@ +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 +} + +export interface ChangelogProvider { + fetchChangelog(now?: number, opts?: { force?: boolean }): Promise +} diff --git a/server/usecases/ports/cloud-store.ts b/server/usecases/ports/cloud-store.ts new file mode 100644 index 00000000..5886f90c --- /dev/null +++ b/server/usecases/ports/cloud-store.ts @@ -0,0 +1,21 @@ +import type { CloudOrderQuotaChange } from '@shared/schemas' +import type { CloudStoreTarget } from '@shared/types' + +export interface CloudStoreBinding { + boundLicenseId: string + storeId: string + refreshToken: string + instanceId: string +} + +export interface CloudStoreRepo { + getAccessibleTargets(userId: string): Promise + // Throws Error('quota_store_binding_missing') when no bound store exists. + getCloudStoreBinding(): Promise + getCustomerLabel(userId: string, orgId: string): Promise + processCloudOrderQuotaChange( + event: CloudOrderQuotaChange, + rawPayload: string, + payloadHash: string, + ): Promise<{ duplicate: boolean; eventId: string }> +} diff --git a/server/usecases/ports/cloud-traffic-report.ts b/server/usecases/ports/cloud-traffic-report.ts new file mode 100644 index 00000000..e7518b24 --- /dev/null +++ b/server/usecases/ports/cloud-traffic-report.ts @@ -0,0 +1,47 @@ +export type TrafficReportSource = + | 'object_download' + | 'direct_share' + | 'landing_share' + | 'image_hosting' + | 'custom_domain_image' + | 'webdav_download' + +export type CloudTrafficReportStatus = 'pending' | 'reported' | 'skipped_unbound' | 'blocked' | 'failed' + +export interface CloudTrafficReportRecord { + id: string + orgId: string + period: string + source: string + sourceId: string + eventId: string + bytes: number + storageId: string | null + unitBytes: number | null + creditsPerUnit: number | null + status: CloudTrafficReportStatus + error: string | null + createdAt: Date + updatedAt: Date +} + +export interface InsertCloudTrafficReportInput { + orgId: string + period: string + source: TrafficReportSource + sourceId: string + eventId: string + bytes: number + storageId: string + unitBytes: number + creditsPerUnit: number + status: CloudTrafficReportStatus + now: Date +} + +export interface CloudTrafficReportRepo { + findByEventId(eventId: string): Promise + insert(input: InsertCloudTrafficReportInput): Promise + updateStatus(eventId: string, status: CloudTrafficReportStatus, error: string | null, now: Date): Promise + listPending(limit: number): Promise +} diff --git a/server/usecases/ports/download-tokens.ts b/server/usecases/ports/download-tokens.ts new file mode 100644 index 00000000..cccd8267 --- /dev/null +++ b/server/usecases/ports/download-tokens.ts @@ -0,0 +1,39 @@ +import type { Database, Platform } from '../../platform/interface' + +export const DOWNLOAD_TOKEN_VERSION = 1 + +export interface DownloaderTokenClaims { + v: typeof DOWNLOAD_TOKEN_VERSION + typ: 'downloader' + downloaderId: string + jti: string + iat: number +} + +export interface TaskUploadTokenClaims { + v: typeof DOWNLOAD_TOKEN_VERSION + typ: 'download-task-upload' + taskId: string + downloaderId: string + orgId: string + targetFolder: string + createdByUserId: string + scopes: string[] + jti: string + iat: number + exp: number +} + +export type DownloadTokenClaims = DownloaderTokenClaims | TaskUploadTokenClaims + +// Signed, HMAC-backed download tokens. Crypto + the secret come from the +// platform per call; the resolve* methods cross-check the signed claims against +// the downloader/task rows. The secret is derived from BETTER_AUTH_SECRET (or +// DOWNLOAD_TOKEN_SECRET) and the gateway throws if neither is set. +export interface DownloadTokenGateway { + signDownloadToken(platform: Platform, claims: DownloadTokenClaims): Promise + verifyDownloadToken(platform: Platform, token: string): Promise + hashDownloadToken(platform: Platform, token: string): Promise + resolveDownloaderToken(platform: Platform, token: string): Promise<{ downloaderId: string } | null> + resolveTaskUploadToken(db: Database, platform: Platform, token: string): Promise +} diff --git a/server/usecases/ports/downloads.ts b/server/usecases/ports/downloads.ts new file mode 100644 index 00000000..066631fe --- /dev/null +++ b/server/usecases/ports/downloads.ts @@ -0,0 +1,207 @@ +import type { Downloader, DownloadTask } from '@shared/types' + +// ─── Errors ────────────────────────────────────────────────────────────────── +// Thrown by the repos (not_found/forbidden) and the orchestration state machine +// (invalid_state/no_downloader/unsupported_source). Caught by the http layer +// (download-tasks.ts / downloaders.ts) and mapped to 404/403/409. +export class DownloadError extends Error { + constructor( + readonly code: 'not_found' | 'forbidden' | 'no_downloader' | 'invalid_state' | 'unsupported_source', + message: string = code, + ) { + super(message) + this.name = 'DownloadError' + } +} + +// ─── DTOs ────────────────────────────────────────────────────────────────── +// Plain records mirroring the `downloaders` / `download_tasks` tables. Timestamps +// stay Date (the http layer serializes the API-shaped Downloader/DownloadTask). +// Drizzle row types never cross this port; the orchestration state machine reads +// these records and composes the repo write primitives. + +export interface DownloaderRecord { + id: string + name: string + tokenHash: string + tokenJti: string + status: string + enabled: boolean + version: string + hostname: string + platform: string + arch: string + engine: string + capabilities: string[] + maxConcurrentTasks: number + currentTasks: number + downloadBps: number + uploadBps: number + freeDiskBytes: number + remoteDownloadCreditBillingEnabled: boolean + remoteDownloadCreditUnitBytes: number + remoteDownloadCreditPerUnit: number + lastHeartbeatAt: Date | null + createdBy: string + createdAt: Date + updatedAt: Date +} + +export interface DownloadTaskRecord { + id: string + orgId: string + createdByUserId: string + sourceType: string + sourceUri: string + displayName: string | null + targetFolder: string + category: string | null + tags: string + assignedDownloaderId: string | null + status: string + attempt: number + billingAuthorizedBytes: number + billingChargedBytes: number + billingChargedCredits: number + billingStatus: string + errorCode: string | null + errorMessage: string | null + resultObjectId: string | null + runtime: string | null + createdAt: Date + updatedAt: Date + assignedAt: Date | null + startedAt: Date | null + finishedAt: Date | null +} + +export interface CreateDownloaderRecordInput { + id: string + name: string + tokenHash: string + tokenJti: string + version: string + hostname: string + platform: string + arch: string + engine: string + capabilities: string[] + maxConcurrentTasks: number + currentTasks: number + downloadBps: number + uploadBps: number + freeDiskBytes: number + remoteDownloadCreditUnitBytes: number + createdBy: string + now: Date +} + +export interface UpdateDownloaderFields { + name?: string + enabled?: boolean + remoteDownloadCreditBillingEnabled?: boolean + remoteDownloadCreditUnitBytes?: number + remoteDownloadCreditPerUnit?: number +} + +export interface DownloaderHeartbeatFields { + version: string + hostname: string + platform: string + arch: string + engine: string + capabilities: string[] + maxConcurrentTasks: number + currentTasks: number + downloadBps: number + uploadBps: number + freeDiskBytes: number +} + +export interface ListDownloadTasksFilters { + orgId?: string + downloaderId?: string + status?: string + category?: string + tag?: string + sortBy?: 'createdAt' | 'source' | 'category' | 'tags' | 'status' | 'progress' | 'eta' + sortDir?: 'asc' | 'desc' + page: number + pageSize: number +} + +export interface CreateDownloadTaskRecordInput { + id: string + orgId: string + createdByUserId: string + sourceType: string + sourceUri: string + displayName: string | null + targetFolder: string + category: string | null + tags: string[] + assignedDownloaderId: string | null + status: string + assignedAt: Date | null + now: Date +} + +// Owns the `downloaders` table: registration, admin CRUD, heartbeat persistence, +// candidate selection, and stale-lease recovery. Read methods return the API DTO +// (toDownloader folded in); the orchestration reads records for the state machine. +export interface DownloaderRepo { + insert(input: CreateDownloaderRecordInput): Promise + list(): Promise + /** API DTO by id; throws DownloadError('not_found') when missing. */ + get(id: string): Promise + /** Raw record by id; throws DownloadError('not_found') when missing. */ + getRecord(id: string): Promise + findRecord(id: string): Promise + update(id: string, fields: UpdateDownloaderFields, now: Date): Promise + recordHeartbeat(id: string, fields: DownloaderHeartbeatFields, online: boolean, now: Date): Promise + delete(id: string): Promise + /** Online, enabled, under-capacity downloaders, ordered for assignment. */ + listAssignmentCandidates(leaseCutoff: Date): Promise + /** Ids of online, enabled downloaders whose last heartbeat is older than the cutoff. */ + listStaleIds(leaseCutoff: Date): Promise + markStaleOffline(ids: string[], now: Date): Promise +} + +// Owns the `download_tasks` table: CRUD, listing/ordering, and the write +// primitives the orchestration state machine composes. Read methods return the +// API DTO (toDownloadTask folded in); the orchestration reads records. +export interface DownloadTaskRepo { + insert(input: CreateDownloadTaskRecordInput): Promise + list(filters: ListDownloadTasksFilters): Promise<{ items: DownloadTask[]; total: number; rows: DownloadTaskRecord[] }> + /** API DTO scoped to org; throws DownloadError('not_found') when missing. */ + get(orgId: string, id: string): Promise + /** Raw record scoped to org; throws DownloadError('not_found') when missing. */ + getRecord(orgId: string, id: string): Promise + findRecord(id: string): Promise + setFields(id: string, fields: UpdateDownloadTaskFields): Promise + delete(id: string): Promise + /** Oldest queued tasks awaiting assignment. */ + listQueued(limit: number): Promise + /** Re-queue a downloader's in-flight tasks (delete-downloader requeue). */ + requeueAssignedTo(downloaderId: string, statuses: string[], now: Date): Promise + /** Re-queue in-flight tasks held by stale downloaders. */ + requeueAssignedToMany(downloaderIds: string[], statuses: string[], now: Date): Promise +} + +export interface UpdateDownloadTaskFields { + status?: string + assignedDownloaderId?: string | null + attempt?: number + billingAuthorizedBytes?: number + billingChargedBytes?: number + billingChargedCredits?: number + billingStatus?: string + errorCode?: string | null + errorMessage?: string | null + resultObjectId?: string | null + runtime?: string | null + assignedAt?: Date | null + startedAt?: Date | null + finishedAt?: Date | null + updatedAt: Date +} diff --git a/server/usecases/ports/email.ts b/server/usecases/ports/email.ts new file mode 100644 index 00000000..401e9f34 --- /dev/null +++ b/server/usecases/ports/email.ts @@ -0,0 +1,43 @@ +import type { Platform } from '../../platform/interface' + +export interface EmailMessage { + to: string + subject: string + html: string + text?: string +} + +export type EmailProvider = 'smtp' | 'http' | 'cloudflare' + +export interface SmtpConfig { + host: string + port: number + user: string + pass: string + secure: boolean +} + +export interface HttpConfig { + url: string + apiKey: string +} + +export type EmailConfig = + | { provider: 'smtp'; from: string; smtp: SmtpConfig } + | { provider: 'http'; from: string; http: HttpConfig } + | { provider: 'cloudflare'; from: string } + +export interface EmailSettings { + enabled: boolean + config: EmailConfig | null +} + +// Outbound transactional email. Config is read from system options; the +// Cloudflare provider also needs the platform's EMAIL binding, so the platform +// is passed per call rather than captured at construction. +export interface EmailGateway { + getConfig(platform: Platform): Promise + getSettings(platform: Platform): Promise + isConfigured(platform: Platform): Promise + send(platform: Platform, message: EmailMessage): Promise +} diff --git a/server/usecases/ports/image-hosting-config.ts b/server/usecases/ports/image-hosting-config.ts new file mode 100644 index 00000000..a79f77ff --- /dev/null +++ b/server/usecases/ports/image-hosting-config.ts @@ -0,0 +1,30 @@ +export interface ImageHostingConfigRecord { + orgId: string + customDomain: string | null + cfHostnameId: string | null + domainVerifiedAt: Date | null + refererAllowlist: string | null + createdAt: Date + updatedAt: Date +} + +export interface CreateImageHostingConfigInput { + orgId: string + customDomain: string | null + cfHostnameId: string | null + refererAllowlist: string | null +} + +export interface UpdateImageHostingConfigInput { + customDomain?: string | null + cfHostnameId?: string | null + domainVerifiedAt?: Date | null + refererAllowlist?: string | null +} + +export interface ImageHostingConfigRepo { + getByOrg(orgId: string): Promise + create(input: CreateImageHostingConfigInput): Promise + update(orgId: string, set: UpdateImageHostingConfigInput): Promise + delete(orgId: string): Promise +} diff --git a/server/usecases/ports/image-hosting.ts b/server/usecases/ports/image-hosting.ts new file mode 100644 index 00000000..51579e76 --- /dev/null +++ b/server/usecases/ports/image-hosting.ts @@ -0,0 +1,48 @@ +import type { AllowedImageMime } from '@shared/schemas' +import type { ImageHosting } from '@shared/types' + +// Server-side record: the shared DTO, but timestamps stay as Date until the http +// layer serializes them. Drizzle row types never cross this boundary. +export type ImageHostingRecord = Omit & { + lastAccessedAt: Date | null + createdAt: Date +} + +export interface CreateImageHostingInput { + orgId: string + path: string + mime: AllowedImageMime + size: number + storageId: string + status: 'draft' | 'active' +} + +export interface ListImageHostingsOptions { + pathPrefix?: string + cursor?: string + limit: number +} + +export interface ImageResolution { + image: ImageHostingRecord + refererAllowlist: string[] +} + +export interface ImageHostingRepo { + // Redirect resolution. Both return the active image plus its org's referer + // allowlist (parsed): the /r/:token ih_ path and the custom-domain middleware. + resolveActiveByToken(token: string): Promise + resolveActiveByOrgPath(orgId: string, path: string): Promise + resolveCustomDomain(host: string): Promise + incrementAccessCount(id: string): Promise + + // CRUD. `create` resolves a unique path on collision before inserting. + create(input: CreateImageHostingInput): Promise + get(id: string, orgId: string): Promise + list( + orgId: string, + opts: ListImageHostingsOptions, + ): Promise<{ items: ImageHostingRecord[]; nextCursor: string | null }> + setActive(id: string, orgId: string): Promise + delete(id: string, orgId: string): Promise +} diff --git a/server/usecases/ports/image-upload.ts b/server/usecases/ports/image-upload.ts new file mode 100644 index 00000000..b06b95a9 --- /dev/null +++ b/server/usecases/ports/image-upload.ts @@ -0,0 +1,16 @@ +import type { Platform } from '../../platform/interface' + +export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp'] as const +export type ImageMime = (typeof IMAGE_MIMES)[number] +export const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2 MiB + +export type ImageUploadResult = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string } + +// Stream-proxy public images (avatars, org logos) to the workspace's public +// image backend: an R2 binding on CF (zero-auth, zero-egress) or the +// user-configured public S3 storage everywhere else. The platform is passed +// per call because the R2 binding + public-URL env are request-scoped. +export interface ImageUpload { + uploadPublicImage(platform: Platform, prefix: string, id: string, file: File): Promise + deletePublicImageVariants(platform: Platform, prefix: string, id: string): Promise +} diff --git a/server/usecases/ports/invite.ts b/server/usecases/ports/invite.ts new file mode 100644 index 00000000..4ce738ef --- /dev/null +++ b/server/usecases/ports/invite.ts @@ -0,0 +1,17 @@ +export interface InviteCodeRecord { + id: string + code: string + createdBy: string + usedBy: string | null + usedAt: Date | null + expiresAt: Date | null + createdAt: Date +} + +export interface InviteRepo { + generate(adminUserId: string, quantity: number, expiresAt?: Date): Promise + validate(code: string): Promise<{ valid: boolean; error?: string }> + redeem(code: string, userId: string): Promise<'ok' | 'not_found' | 'already_used' | 'expired'> + list(page: number, pageSize: number): Promise<{ items: InviteCodeRecord[]; total: number }> + delete(codeId: string): Promise<'ok' | 'not_found' | 'already_used'> +} diff --git a/server/usecases/ports/license-binding.ts b/server/usecases/ports/license-binding.ts new file mode 100644 index 00000000..532a24ab --- /dev/null +++ b/server/usecases/ports/license-binding.ts @@ -0,0 +1,54 @@ +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 interface CreateLicenseBindingInput { + cloudBindingId: string + cloudStoreId?: string | null + instanceId: string + cloudAccountId: string + cloudAccountEmail?: string | null + refreshToken: string + cachedCert: string + cachedExpiresAt: number + lastRefreshAt: number +} + +export interface UpdateLicenseBindingInput { + id: string + refreshToken: string + cloudStoreId?: string | null + cachedCert: string + cachedExpiresAt: number + cloudAccountEmail?: string | null + lastRefreshAt: number +} + +export interface LicenseBindingRepo { + loadLicenseState(): Promise + loadActiveLicenseBinding(): Promise + createLicenseBinding(input: CreateLicenseBindingInput): Promise + updateLicenseBindingAfterRefresh(input: UpdateLicenseBindingInput): Promise + setLicenseRefreshError(id: string, error: string): Promise + clearLicenseBinding(status?: LicenseBindingStatus): Promise +} + +export interface InstanceRepo { + getOrCreateInstanceId(): Promise + getInstanceDisplayName(): Promise +} diff --git a/server/usecases/ports/licensing-cloud.ts b/server/usecases/ports/licensing-cloud.ts new file mode 100644 index 00000000..d69be2d6 --- /dev/null +++ b/server/usecases/ports/licensing-cloud.ts @@ -0,0 +1,92 @@ +import type { z } from 'zod' +import type { CloudClient } from 'zpan-cloud-sdk' + +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' + } +} + +export interface LicensingCloudGateway { + createPairing(baseUrl: string, instance: CloudInstanceInfo): Promise + pollPairing(baseUrl: string, code: string): Promise + // Throws CloudUnboundError on 401, CloudNetworkError on network failure. + refreshEntitlement( + baseUrl: string, + refreshToken: string, + instance?: CloudInstanceInfo, + ): Promise + unbindCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise + confirmCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise + createBoundCloudClient(baseUrl: string, refreshToken: string): CloudClient + requestCloudJson( + response: Promise<{ status: number; ok: boolean; json(): Promise; text(): Promise }>, + responseSchema?: z.ZodType, + ): Promise +} diff --git a/server/usecases/ports/matter.ts b/server/usecases/ports/matter.ts new file mode 100644 index 00000000..4885614e --- /dev/null +++ b/server/usecases/ports/matter.ts @@ -0,0 +1,150 @@ +import type { ConflictStrategy } from '@shared/schemas' + +export type { ConflictStrategy } from '@shared/schemas' + +// ─── DTOs ────────────────────────────────────────────────────────────────── +// Plain record mirroring the `matters` table. Timestamps stay Date (the http +// layer serializes them). Drizzle row types never cross this port. + +export interface Matter { + id: string + orgId: string + alias: string + name: string + type: string + size: number | null + dirtype: number | null + parent: string + object: string + storageId: string + status: string + trashedAt: number | null + createdAt: Date + updatedAt: Date +} + +export interface CreateMatterInput { + orgId: string + userId?: string + name: string + type: string + size?: number + dirtype?: number + parent?: string + object: string + storageId: string + status: string + /** How to handle name collision with an existing active sibling. Default 'fail'. */ + onConflict?: ConflictStrategy +} + +export interface MatterListFilters { + parent?: string + status: string + page: number + pageSize: number + typeFilter?: string + search?: string +} + +export interface MatterListResult { + items: Matter[] + total: number + page: number + pageSize: number +} + +export interface UpdateMatterInput { + name?: string + parent?: string + onConflict?: ConflictStrategy +} + +export interface CopyMatterOptions { + onConflict?: ConflictStrategy + userId?: string +} + +export interface ConflictResolveOptions { + /** Exclude a specific row from the conflict check (rename/move cases). */ + excludeId?: string + /** Incoming item is a folder — replace is disabled for folders. */ + isFolder?: boolean + /** Activity log will record a 'replace' event for this user when replace trashes a row. */ + userId?: string +} + +/** + * Planned resolution of a name conflict. Callers can inspect the plan, run their + * own preconditions (quota, permissions), and commit it via `commitConflictPlan`. + */ +export interface ConflictPlan { + finalName: string + /** Row to trash when committing a 'replace' plan; null otherwise. */ + toTrash: Matter | null +} + +// Thrown by conflict resolution when strategy='fail' or when 'replace' is +// rejected (folder source/target). Caught by lib/http-errors.ts and mapped to +// a 409 with the conflicting name/id. +export class NameConflictError extends Error { + constructor( + public readonly conflictingName: string, + public readonly conflictingId: string, + ) { + super(`An item named '${conflictingName}' already exists in this location`) + this.name = 'NameConflictError' + } +} + +export interface MatterRepo { + create(input: CreateMatterInput): Promise + list(orgId: string, filters: MatterListFilters): Promise + get(id: string, orgId: string): Promise + getMany(orgId: string, ids: string[]): Promise + update(id: string, orgId: string, input: UpdateMatterInput, userId?: string): Promise + copy(source: Matter, targetParent: string, newObject: string, opts?: CopyMatterOptions): Promise + delete(id: string, orgId: string): Promise + cancelDraft(id: string, orgId: string, userId?: string): Promise + trash(orgId: string, id: string, userId?: string): Promise + restore(orgId: string, id: string, userId?: string, onConflict?: ConflictStrategy): Promise + collectForPurge(orgId: string, idOrMatter: string): Promise + collectForPurge(orgId: string, idOrMatter: Matter): Promise + purge(orgId: string, ids: string[]): Promise + // Active descendants of a folder path (parent LIKE `${parentPath}/%`). Used by the + // WebDAV recursive COPY/MOVE to enumerate a subtree. + listActiveDescendants(orgId: string, parentPath: string): Promise + // Raw bulk status flips for WebDAV COPY rollback/overwrite (no conflict handling). + trashByIds(orgId: string, ids: string[]): Promise + restoreActiveByIds(orgId: string, ids: string[]): Promise + // Bump updatedAt only (WebDAV PROPPATCH touches the matter's mtime). + touch(orgId: string, id: string): Promise + // Overwrite a file matter's content fields after a WebDAV PUT to an existing path. + applyUpload(orgId: string, id: string, fields: { type: string; size: number; object: string }): Promise + listTrashedRoots(orgId: string): Promise + /** Distinct orgIds holding at least one trashed matter older than the cutoff (epoch ms). */ + listOrgIdsWithExpiredTrash(cutoff: number): Promise + // Conflict-resolution primitives the confirmUpload usecase composes between + // its quota reservation and the draft→active flip. + findActiveConflict(orgId: string, parent: string, name: string, excludeId?: string): Promise + planConflictResolution( + orgId: string, + parent: string, + name: string, + strategy: ConflictStrategy, + options?: ConflictResolveOptions, + ): Promise + commitConflictPlan(orgId: string, plan: ConflictPlan, userId?: string): Promise + applyConflictResolution( + orgId: string, + parent: string, + name: string, + strategy: ConflictStrategy, + options?: ConflictResolveOptions, + ): Promise + /** + * Flips a draft row to active under `finalName`, scoped to status='draft' as a + * concurrent-confirm safety net. Returns false when no draft row matched (race). + */ + activateDraft(id: string, orgId: string, finalName: string, now: Date): Promise +} diff --git a/server/usecases/ports/member-count.ts b/server/usecases/ports/member-count.ts new file mode 100644 index 00000000..55f61678 --- /dev/null +++ b/server/usecases/ports/member-count.ts @@ -0,0 +1,6 @@ +// Read-only membership counts used by team-limit enforcement. Kept separate +// from the authz OrgRepo because it answers a quota question (how many orgs a +// user belongs to), not an access-control one. +export interface MemberCountRepo { + countUserOrgs(userId: string): Promise +} diff --git a/server/usecases/ports/notification.ts b/server/usecases/ports/notification.ts new file mode 100644 index 00000000..93abd6b5 --- /dev/null +++ b/server/usecases/ports/notification.ts @@ -0,0 +1,38 @@ +import type { NotificationType } from '@shared/types' + +export interface NotificationRecord { + id: string + userId: string + type: NotificationType + title: string + body: string + refType: string | null + refId: string | null + metadata: string | null + readAt: Date | null + createdAt: Date +} + +export interface CreateNotificationInput { + userId: string + type: NotificationType + title: string + body?: string + refType?: string + refId?: string + metadata?: string +} + +export interface ListNotificationsResult { + items: NotificationRecord[] + total: number + unreadCount: number +} + +export interface NotificationRepo { + create(input: CreateNotificationInput): Promise + list(userId: string, opts: { page: number; pageSize: number; unreadOnly?: boolean }): Promise + markAsRead(userId: string, id: string): Promise + markAllAsRead(userId: string): Promise<{ count: number }> + unreadCount(userId: string): Promise +} diff --git a/server/usecases/ports/object-upload-session.ts b/server/usecases/ports/object-upload-session.ts new file mode 100644 index 00000000..28126d2b --- /dev/null +++ b/server/usecases/ports/object-upload-session.ts @@ -0,0 +1,38 @@ +// Server-side record: the shared ObjectUploadSession DTO, but timestamps stay as +// Date until the usecase serializes them for http. Drizzle rows never cross here. +import type { ObjectUploadSession } from '@shared/types' + +export type ObjectUploadSessionRecord = Omit & { + storageKey: string + expiresAt: Date + createdAt: Date + updatedAt: Date +} + +export interface CreateObjectUploadSessionInput { + orgId: string + objectId: string + storageId: string + storageKey: string + uploadId: string + partSize: number + actorId: string +} + +// Port-level error thrown by the usecase, caught by the http layer and mapped to +// 404 / 409 / 502. +export class ObjectUploadSessionError extends Error { + constructor( + readonly code: 'not_found' | 'invalid_state' | 'storage_failure', + message?: string, + ) { + super(message ?? code) + this.name = 'ObjectUploadSessionError' + } +} + +export interface ObjectUploadSessionRepo { + create(input: CreateObjectUploadSessionInput): Promise + get(orgId: string, objectId: string, id: string): Promise + setStatus(id: string, status: 'completed' | 'aborted'): Promise +} diff --git a/server/usecases/ports/org.ts b/server/usecases/ports/org.ts new file mode 100644 index 00000000..c6f90015 --- /dev/null +++ b/server/usecases/ports/org.ts @@ -0,0 +1,7 @@ +export interface OrgRepo { + findPersonalOrg(userId: string): Promise + getMemberRole(orgId: string, userId: string): Promise + canReadOrg(userId: string, orgId: string): Promise + canWriteToOrg(userId: string, orgId: string): Promise + isPersonalOrg(orgId: string): Promise +} diff --git a/server/usecases/ports/profile.ts b/server/usecases/ports/profile.ts new file mode 100644 index 00000000..153cd685 --- /dev/null +++ b/server/usecases/ports/profile.ts @@ -0,0 +1,10 @@ +export interface PublicUser { + username: string + name: string + image: string | null +} + +export interface ProfileRepo { + getUserByUsername(username: string): Promise + setAvatar(userId: string, image: string | null): Promise +} diff --git a/server/usecases/ports/quota.ts b/server/usecases/ports/quota.ts new file mode 100644 index 00000000..55054757 --- /dev/null +++ b/server/usecases/ports/quota.ts @@ -0,0 +1,53 @@ +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 interface OrgQuotaOverviewRow { + id: string + orgId: string + orgName: string + orgMetadata: string | null +} + +export interface QuotaRepo { + listOrgQuotaOverview(): Promise + getEffectiveQuota(orgId: string, now?: Date): Promise + getEffectiveQuotasByOrg(orgIds: string[], now?: Date): Promise> + resetExpiredTrafficQuotas(now?: Date): Promise + hasQuotaForBytes(orgId: string, bytes: number): Promise + hasTrafficQuotaForBytes(orgId: string, bytes: number, now?: Date): Promise + consumeTrafficIfQuotaAllows(orgId: string, bytes: number, now?: Date): Promise + refundTraffic(orgId: string, bytes: number, now?: Date): Promise + incrementUsageIfEffectiveQuotaAllows( + orgId: string, + storageId: string, + bytes: number, + teamQuotaEnabled?: boolean, + now?: Date, + ): Promise +} diff --git a/server/usecases/ports/remote-download-usage.ts b/server/usecases/ports/remote-download-usage.ts new file mode 100644 index 00000000..57de8dc0 --- /dev/null +++ b/server/usecases/ports/remote-download-usage.ts @@ -0,0 +1,34 @@ +export type RemoteDownloadUsageStatus = 'pending' | 'reported' | 'skipped_unbound' | 'blocked' | 'failed' + +export interface RemoteDownloadUsageReportRecord { + id: string + orgId: string + downloaderId: string + taskId: string + eventId: string + unitIndex: number + unitBytes: number + creditsPerUnit: number + status: RemoteDownloadUsageStatus + error: string | null + createdAt: Date + updatedAt: Date +} + +export interface InsertRemoteDownloadUsageReportInput { + orgId: string + downloaderId: string + taskId: string + eventId: string + unitIndex: number + unitBytes: number + creditsPerUnit: number + now: Date +} + +export interface RemoteDownloadUsageRepo { + findByEventId(eventId: string): Promise + insert(input: InsertRemoteDownloadUsageReportInput): Promise + updateStatus(eventId: string, status: RemoteDownloadUsageStatus, error: string | null, now: Date): Promise + listPending(limit: number): Promise +} diff --git a/server/usecases/ports/s3.ts b/server/usecases/ports/s3.ts new file mode 100644 index 00000000..4c9c63f3 --- /dev/null +++ b/server/usecases/ports/s3.ts @@ -0,0 +1,63 @@ +// The subset of a storage row the S3 client needs. A StorageRecord structurally +// satisfies this, so callers pass storage records straight through. +export interface S3StorageCredentials { + bucket: string + endpoint: string + region: string + accessKey: string + secretKey: string + customHost: string | null +} + +export interface S3Gateway { + presignUpload( + storage: S3StorageCredentials, + key: string, + contentType: string, + filenameOrExpiresIn?: string | number, + expiresIn?: number, + ): Promise + createMultipartUpload(storage: S3StorageCredentials, key: string, contentType: string): Promise + presignUploadPart( + storage: S3StorageCredentials, + key: string, + uploadId: string, + partNumber: number, + expiresIn?: number, + ): Promise + completeMultipartUpload( + storage: S3StorageCredentials, + key: string, + uploadId: string, + parts: Array<{ etag: string; partNumber: number }>, + ): Promise + abortMultipartUpload(storage: S3StorageCredentials, key: string, uploadId: string): Promise + presignDownload(storage: S3StorageCredentials, key: string, filename: string, expiresIn?: number): Promise + presignInline(storage: S3StorageCredentials, key: string, mime: string, expiresIn?: number): Promise + getPublicUrl(storage: S3StorageCredentials, key: string): string + headObject(storage: S3StorageCredentials, key: string): Promise<{ size: number; contentType: string }> + getObjectBytes(storage: S3StorageCredentials, key: string, range?: string): Promise + getObjectBody(storage: S3StorageCredentials, key: string, range?: string): Promise + getObjectStream(storage: S3StorageCredentials, key: string, range?: string): Promise> + copyObject( + srcStorage: S3StorageCredentials, + srcKey: string, + dstStorage: S3StorageCredentials, + dstKey: string, + ): Promise + streamCopy( + srcStorage: S3StorageCredentials, + srcKey: string, + dstStorage: S3StorageCredentials, + dstKey: string, + ): Promise + putObject( + storage: S3StorageCredentials, + key: string, + body: ReadableStream | Uint8Array, + contentType: string, + contentLength?: number, + ): Promise + deleteObject(storage: S3StorageCredentials, key: string): Promise + deleteObjects(storage: S3StorageCredentials, keys: string[]): Promise +} diff --git a/server/usecases/ports/share-notification.ts b/server/usecases/ports/share-notification.ts new file mode 100644 index 00000000..949600a9 --- /dev/null +++ b/server/usecases/ports/share-notification.ts @@ -0,0 +1,17 @@ +// Plain DTO describing the share being announced — decoupled from the (still +// unmigrated) share row type so the usecase stays framework-free. +export interface ShareNotificationShare { + id: string + token: string + kind: 'landing' | 'direct' + expiresAt: Date | null +} + +export interface ShareNotificationRecipient { + recipientUserId?: string | null + recipientEmail?: string | null +} + +export interface ShareNotificationRepo { + getUserEmail(userId: string): Promise +} diff --git a/server/usecases/ports/share.ts b/server/usecases/ports/share.ts new file mode 100644 index 00000000..a21299ff --- /dev/null +++ b/server/usecases/ports/share.ts @@ -0,0 +1,96 @@ +import type { CreateShareInput } from '@shared/schemas/share' +import type { Matter } from './matter' + +// ─── DTOs ────────────────────────────────────────────────────────────────── +// Plain records mirroring the shares / share_recipients tables. Timestamps stay +// Date (the http layer serializes them). Drizzle row types never cross this port. + +export interface ShareRecord { + id: string + token: string + kind: string + matterId: string + orgId: string + creatorId: string + passwordHash: string | null + expiresAt: Date | null + downloadLimit: number | null + views: number + downloads: number + status: string + createdAt: Date +} + +export interface ShareRecipientRecord { + id: string + shareId: string + recipientUserId: string | null + recipientEmail: string | null + createdAt: Date +} + +export interface ShareListItem { + id: string + token: string + kind: string + matterId: string + orgId: string + creatorId: string + expiresAt: Date | null + downloadLimit: number | null + views: number + downloads: number + status: string + createdAt: Date + matter: { name: string; type: string; dirtype: number } + recipientCount: number + creatorName?: string +} + +export type ShareResolution = + | { status: 'ok'; share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] } + | { status: 'not_found' | 'revoked' | 'matter_trashed' } + +// Thrown by createShare on invalid share-shape combinations. Carries a stable +// code the http layer maps to a 400/404. +export class CreateShareError extends Error { + constructor(public code: 'MATTER_NOT_FOUND' | 'DIRECT_NO_FOLDER' | 'DIRECT_NO_PASSWORD' | 'DIRECT_NO_RECIPIENTS') { + super(code) + } +} + +export interface ShareRepo { + create(input: CreateShareInput): Promise + resolveByToken(token: string): Promise + incrementViews(shareId: string): Promise + hasDownloadsAvailable(shareId: string): Promise + incrementDownloadsAtomic(shareId: string): Promise<{ ok: boolean; downloads: number }> + decrementDownloads(shareId: string): Promise + listRecipientUserIds(shareId: string): Promise + cascadeDeleteByMatter(matterId: string): Promise + getCreatorByToken(token: string): Promise + revokeByToken(token: string, creatorId: string): Promise + listForApi( + creatorId: string, + opts: { page: number; pageSize: number; status?: string }, + ): Promise<{ items: ShareListItem[]; total: number }> + listReceivedForApi( + userId: string, + userEmail: string | null, + opts: { page: number; pageSize: number }, + ): Promise<{ items: ShareListItem[]; total: number }> + // Matter reads supporting the save-to-drive flow. They read the matters table + // and are co-located in the share repo while matter remains unmigrated. + computeSourceBytes(matter: Matter): Promise + listDirectActiveChildren(orgId: string, folderPath: string): Promise + hasQuotaForBytes(orgId: string, bytes: number): Promise + // Lookups the share routes need; co-located here while user/matter are + // unmigrated so the share http layer holds no drizzle. + getCreatorName(creatorId: string): Promise + getUserEmail(userId: string): Promise + getMatterName(matterId: string): Promise + findShareChildMatter( + rootMatter: { id: string; orgId: string; parent: string; name: string }, + childId: string, + ): Promise +} diff --git a/server/usecases/ports/site-invitation.ts b/server/usecases/ports/site-invitation.ts new file mode 100644 index 00000000..e6f652f0 --- /dev/null +++ b/server/usecases/ports/site-invitation.ts @@ -0,0 +1,18 @@ +import type { SiteInvitation } from '@shared/types' + +export type ResendSiteInvitationResult = SiteInvitation | 'not_found' | 'already_accepted' | 'already_revoked' + +export type RevokeSiteInvitationResult = 'ok' | 'not_found' | 'already_accepted' | 'already_revoked' + +export type AcceptSiteInvitationResult = 'ok' | 'not_found' | 'revoked' | 'accepted' | 'expired' | 'email_mismatch' + +export interface SiteInvitationRepo { + getSiteName(): Promise + listSiteInvitations(page: number, pageSize: number): Promise<{ items: SiteInvitation[]; total: number }> + createSiteInvitation(adminUserId: string, rawEmail: string): Promise + resendSiteInvitation(invitationId: string): Promise + revokeSiteInvitation(invitationId: string, adminUserId: string): Promise + getSiteInvitationByToken(token: string): Promise + validateSiteInvitation(token: string, rawEmail: string): Promise<{ valid: boolean; error?: string }> + acceptSiteInvitation(token: string, rawEmail: string, userId: string): Promise +} diff --git a/server/usecases/ports/storage-usage.ts b/server/usecases/ports/storage-usage.ts new file mode 100644 index 00000000..a068c9a5 --- /dev/null +++ b/server/usecases/ports/storage-usage.ts @@ -0,0 +1,23 @@ +export class StorageQuotaExceededError extends Error { + constructor() { + super('QUOTA_EXCEEDED') + this.name = 'StorageQuotaExceededError' + } +} + +export interface StorageUsageReservation { + orgId: string + storageId: string + bytes: number +} + +export interface ReserveStorageUsageInput extends StorageUsageReservation { + teamQuotaEnabled?: boolean +} + +export interface StorageUsageRepo { + // Decrement org + storage `used` counters for the given reservations (floored at 0). + rollbackReservations(reservations: Iterable): Promise + // Recompute org (and optionally specific storages') `used` from live matter/image rows. + reconcile(orgId: string, storageIds?: Iterable): Promise +} diff --git a/server/usecases/ports/storage.ts b/server/usecases/ports/storage.ts new file mode 100644 index 00000000..aa164119 --- /dev/null +++ b/server/usecases/ports/storage.ts @@ -0,0 +1,21 @@ +import type { CreateStorageInput, UpdateStorageInput } from '@shared/schemas' +import type { Storage } from '@shared/types' + +// Server-side record: the shared DTO, but timestamps stay as Date until the http +// layer serializes them. Drizzle row types never cross this boundary. +export type StorageRecord = Omit & { + createdAt: Date + updatedAt: Date +} + +export type DeleteStorageResult = 'ok' | 'not_found' | 'in_use' + +export interface StorageRepo { + list(): Promise<{ items: StorageRecord[]; total: number }> + get(id: string): Promise + create(input: CreateStorageInput): Promise + count(): Promise + update(id: string, input: UpdateStorageInput): Promise + delete(id: string): Promise + select(mode: 'private' | 'public'): Promise +} diff --git a/server/usecases/ports/system-options.ts b/server/usecases/ports/system-options.ts new file mode 100644 index 00000000..3e4dd877 --- /dev/null +++ b/server/usecases/ports/system-options.ts @@ -0,0 +1,15 @@ +export interface SystemOption { + key: string + value: string + public: boolean +} + +export interface SystemOptionsRepo { + list(): Promise + listPublic(): Promise + get(key: string): Promise + getValue(key: string): Promise + listByKeyLike(pattern: string): Promise> + set(key: string, value: string, isPublic: boolean): Promise + delete(key: string): Promise +} diff --git a/server/usecases/ports/team-invite.ts b/server/usecases/ports/team-invite.ts new file mode 100644 index 00000000..98a3fef3 --- /dev/null +++ b/server/usecases/ports/team-invite.ts @@ -0,0 +1,38 @@ +export interface TeamInviteLinkRecord { + id: string + token: string + organizationId: string + role: string + inviterId: string + expiresAt: Date + createdAt: Date +} + +export interface InviteLinkInfo { + organizationId: string + organizationName: string + role: string + expiresAt: Date | null +} + +export type AcceptInviteResult = 'ok' | 'invalid' | 'expired' | 'already_member' + +export interface PendingInvitation { + id: string + email: string + role: string + expiresAt: Date | null + createdAt: Date +} + +export interface TeamInviteRepo { + createInviteLink( + organizationId: string, + inviterId: string, + role: string, + expiresIn?: number, + ): Promise + getInviteLinkInfo(token: string): Promise + acceptInviteLink(token: string, userId: string): Promise + listPendingInvitations(organizationId: string): Promise +} diff --git a/server/usecases/ports/team.ts b/server/usecases/ports/team.ts new file mode 100644 index 00000000..64051762 --- /dev/null +++ b/server/usecases/ports/team.ts @@ -0,0 +1,17 @@ +export interface TeamSummary { + id: string + name: string + slug: string + logo: string | null + memberCount: number + ownerName: string | null + quotaUsed: number + quotaTotal: number + createdAt: number +} + +export interface TeamRepo { + listTeams(): Promise + getTeam(orgId: string): Promise + setLogo(orgId: string, logo: string | null): Promise +} diff --git a/server/usecases/ports/user.ts b/server/usecases/ports/user.ts new file mode 100644 index 00000000..98164ccd --- /dev/null +++ b/server/usecases/ports/user.ts @@ -0,0 +1,114 @@ +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 interface GrantEntitlementInput { + adminUserId: string + orgId: string + resourceType: 'storage' + bytes: number + expiresAt?: Date | null + note?: string | null +} + +export interface UpdateEntitlementInput { + adminUserId: string + orgId: string + entitlementId: string + bytes?: number + expiresAt?: Date | null + note?: string | null +} + +export interface EntitlementResult { + orgId: string + entitlement: QuotaEntitlementItem +} + +// Admin surface for user management + storage entitlement grants (org- and +// user-personal-scoped). Operations return UserOperationFailure instead of +// throwing so http maps {status} directly. +export interface UserAdminRepo { + listUsers(page: number, pageSize: number, search?: string): Promise<{ items: UserWithOrg[]; total: number }> + getUser(userId: string): Promise + // Whether the user is banned/disabled — checked by the auth middleware on every + // authenticated request to reject sessions of users disabled mid-session. + isBanned(userId: string): Promise + // Whether `username` matches the user's email or username (WebDAV Basic Auth check). + matchesUsername(userId: string, username: string): Promise + setUserStatus(userId: string, status: 'active' | 'disabled'): Promise + deleteUser(userId: string): Promise + setUsersStatus( + userIds: string[], + status: 'active' | 'disabled', + ): Promise<{ updated: number; ids: string[] } | UserOperationFailure> + deleteUsers(userIds: string[]): Promise<{ deleted: number; ids: string[] } | UserOperationFailure> + + listUserPersonalEntitlements( + userId: string, + ): Promise<{ orgId: string; items: QuotaEntitlementItem[] } | UserOperationFailure> + grantUserPersonalEntitlement(input: { + adminUserId: string + targetUserId: string + resourceType: 'storage' + bytes: number + expiresAt?: Date | null + note?: string | null + }): Promise + updateUserPersonalEntitlement(input: { + adminUserId: string + targetUserId: string + entitlementId: string + bytes?: number + expiresAt?: Date | null + note?: string | null + }): Promise + revokeUserPersonalEntitlement(input: { + adminUserId: string + targetUserId: string + entitlementId: string + }): Promise + + requireOrg(orgId: string): Promise<{ orgId: string } | UserOperationFailure> + listOrgEntitlements(orgId: string): Promise<{ orgId: string; items: QuotaEntitlementItem[] } | UserOperationFailure> + grantOrgEntitlement(input: GrantEntitlementInput): Promise + updateOrgEntitlement(input: UpdateEntitlementInput): Promise + revokeOrgEntitlement(input: { + adminUserId: string + orgId: string + entitlementId: string + }): Promise +} diff --git a/server/usecases/ports/webdav-path.ts b/server/usecases/ports/webdav-path.ts new file mode 100644 index 00000000..b0d316cb --- /dev/null +++ b/server/usecases/ports/webdav-path.ts @@ -0,0 +1,31 @@ +import type { WebDavWorkspace } from '../../domain/webdav' +import type { Matter } from './matter' + +export type { WebDavWorkspace } from '../../domain/webdav' + +export interface WebDavTarget { + workspace: WebDavWorkspace | null + mountRoot: boolean + parent: string + name: string + matter: Matter | null +} + +// Thrown by the path repo when a DAV path is malformed or its workspace/matter +// cannot be resolved. Caught by lib/http-errors.ts and mapped to the carried +// status (404 not-found, 400 bad-path, 405 not-collection, 409 parent-missing). +export class WebDavPathError extends Error { + constructor( + message: string, + public status: number, + ) { + super(message) + } +} + +export interface WebDavPathRepo { + listUserWorkspaces(userId: string): Promise + listChildren(orgId: string, parent: string): Promise + resolveWebDavPath(userId: string, rawPath: string): Promise + resolveExistingWebDavPath(userId: string, rawPath: string): Promise +} diff --git a/server/usecases/ports/webdav-state.ts b/server/usecases/ports/webdav-state.ts new file mode 100644 index 00000000..edeca25e --- /dev/null +++ b/server/usecases/ports/webdav-state.ts @@ -0,0 +1,32 @@ +import type { DavDeadProperty, DavLock, DavPropertyName } from '../../domain/webdav' + +export type { DavDeadProperty, DavLock, DavPropertyName } from '../../domain/webdav' + +export type DeadPropertyUpdate = + | { action: 'set'; property: DavDeadProperty } + | { action: 'remove'; property: DavPropertyName } + +export interface CreateLockInput { + orgId: string + resourcePath: string + owner: string + depth: string + timeoutSeconds: number +} + +// WebDAV lock + dead-property state. Locks expire on a wall-clock TTL, so reads +// purge expired rows before returning; depth-infinity scoping is resolved in the +// repo (D1 cannot express the dynamic LIKE the scope check needs). +export interface WebDavStateRepo { + listDeadPropertiesForResources(orgId: string, resourcePaths: string[]): Promise> + applyDeadPropertyUpdate(orgId: string, resourcePath: string, operations: DeadPropertyUpdate[]): Promise + copyDeadProperties(orgId: string, sourcePath: string, targetPath: string): Promise + deleteWebDavState(orgId: string, resourcePath: string): Promise + moveWebDavState(orgId: string, oldPath: string, newPath: string): Promise + activeLocks(orgId: string, resourcePath: string): Promise + activeLocksForResources(orgId: string, resourcePaths: string[]): Promise> + conflictingLocks(orgId: string, resourcePath: string): Promise + createLock(input: CreateLockInput): Promise + refreshLock(orgId: string, resourcePath: string, token: string, timeoutSeconds: number): Promise + removeLock(orgId: string, resourcePath: string, token: string): Promise +} diff --git a/server/usecases/ports/zip.ts b/server/usecases/ports/zip.ts new file mode 100644 index 00000000..8da6f545 --- /dev/null +++ b/server/usecases/ports/zip.ts @@ -0,0 +1,112 @@ +export const ZIP_COMPRESS_LIMITS = { + totalInputBytes: 512 * 1024 * 1024, + singleFileBytes: 512 * 1024 * 1024, + fileCount: 1000, + directoryDepth: 10, +} as const + +export const ZIP_EXTRACT_LIMITS = { + totalOutputBytes: 1024 * 1024 * 1024, + singleFileBytes: 1024 * 1024 * 1024, + fileCount: 1000, + directoryDepth: 10, +} as const + +// The matter fields the compression plan needs to stream a source object. A +// drizzle matter row structurally satisfies this, so the repo passes rows straight +// through without leaking the persistence row type into the port. +export interface CompressionSourceMatter { + storageId: string + object: string + size: number | null +} + +export interface CompressionSourceFile { + matter: CompressionSourceMatter + archivePath: string +} + +export interface CompressionSourceDirectory { + archivePath: string +} + +export interface CompressionPlan { + files: CompressionSourceFile[] + directories: CompressionSourceDirectory[] + inputBytes: number + outputName: string + targetFolder: string +} + +export interface CollectCompressionPlanOptions { + targetFolder?: string + outputName?: string +} + +export interface ZipPlanRepo { + collectCompressionPlan( + orgId: string, + matterIds: string[], + opts?: CollectCompressionPlanOptions, + ): Promise +} + +export interface ZipSourceObject { + archivePath: string + bytes: Uint8Array +} + +export interface ZipSourceStream { + archivePath: string + openStream: () => Promise> +} + +export interface ExtractedZipEntry { + path: string + name: string + parentPath: string + bytes: Uint8Array + size: number +} + +export interface ZipDirectoryPlan { + folders: string[] + totalBytes: number + fileCount: number +} + +export interface ValidatedZip { + files: ExtractedZipEntry[] + folders: string[] + totalBytes: number +} + +export interface StreamingZipFile { + path: string + name: string + parentPath: string + stream: ReadableStream + size: Promise +} + +export interface StreamingZipExtraction { + folders: string[] + totalBytes: number +} + +export interface ZipGateway { + createZipArchive(objects: ZipSourceObject[], directories?: CompressionSourceDirectory[]): Uint8Array + createZipArchiveStream( + sources: ZipSourceStream[], + directories?: CompressionSourceDirectory[], + ): ReadableStream + validateAndExtractZip(data: Uint8Array): ValidatedZip + validateZipDirectory( + size: number, + readRange: (start: number, end: number) => Promise, + ): Promise + streamValidatedZip( + data: ReadableStream, + onFile: (file: StreamingZipFile) => Promise, + ): Promise +} diff --git a/server/usecases/purge.ts b/server/usecases/purge.ts new file mode 100644 index 00000000..c996cf34 --- /dev/null +++ b/server/usecases/purge.ts @@ -0,0 +1,47 @@ +import { DirType } from '@shared/constants' +import type { Matter, MatterRepo, S3Gateway, ShareRepo, StorageRecord, StorageRepo, StorageUsageRepo } from './ports' + +export type PurgeDeps = { + s3: S3Gateway + storages: StorageRepo + storageUsage: StorageUsageRepo + share: ShareRepo + matter: MatterRepo +} + +export async function purgeRecursively(deps: PurgeDeps, orgId: string, matters: Matter[]): Promise { + const keysByStorage = new Map() + const bytesByStorage = new Map() + let totalBytes = 0 + + for (const m of matters) { + const size = m.size ?? 0 + if (m.dirtype === DirType.FILE && size > 0) { + bytesByStorage.set(m.storageId, (bytesByStorage.get(m.storageId) ?? 0) + size) + totalBytes += size + } + if (!m.object) continue + let entry = keysByStorage.get(m.storageId) + if (!entry) { + const storage = await deps.storages.get(m.storageId) + entry = { storage, keys: [] } + keysByStorage.set(m.storageId, entry) + } + entry.keys.push(m.object) + } + + for (const { storage, keys } of keysByStorage.values()) { + if (storage && keys.length > 0) await deps.s3.deleteObjects(storage, keys) + } + + for (const m of matters) { + await deps.share.cascadeDeleteByMatter(m.id) + } + + await deps.matter.purge( + orgId, + matters.map((m) => m.id), + ) + if (totalBytes > 0) await deps.storageUsage.reconcile(orgId, bytesByStorage.keys()) + return matters.length +} diff --git a/server/usecases/remote-download-usage.ts b/server/usecases/remote-download-usage.ts new file mode 100644 index 00000000..0774af41 --- /dev/null +++ b/server/usecases/remote-download-usage.ts @@ -0,0 +1,137 @@ +import { z } from 'zod' +import { hasFeature } from '../domain/licensing' +import { loadBindingState } from './licensing' +import type { + LicenseBindingRepo, + LicensingCloudGateway, + RemoteDownloadUsageRepo, + RemoteDownloadUsageReportRecord, + RemoteDownloadUsageStatus, +} from './ports' + +export type RemoteDownloadUsageDeps = { + licenseBinding: LicenseBindingRepo + licensingCloud: LicensingCloudGateway + remoteDownloadUsage: RemoteDownloadUsageRepo +} + +export class RemoteDownloadBillingBlockedError extends Error { + constructor() { + super('insufficient_credits') + this.name = 'RemoteDownloadBillingBlockedError' + } +} + +const usageResponseSchema = z.object({ + accepted: z.boolean(), + duplicate: z.boolean().optional(), + eventId: z.string().min(1), +}) + +export async function reportRemoteDownloadUnit( + deps: RemoteDownloadUsageDeps, + params: { + cloudBaseUrl: string + orgId: string + downloaderId: string + taskId: string + unitIndex: number + unitBytes: number + creditsPerUnit: number + enabled: boolean + }, +): Promise<{ status: RemoteDownloadUsageStatus; eventId: string }> { + if (!params.enabled) return { status: 'reported', eventId: '' } + if (!hasFeature('quota_store', await loadBindingState(deps))) return { status: 'reported', eventId: '' } + const eventId = `remote_download:${params.taskId}:${params.unitIndex}` + const existing = await deps.remoteDownloadUsage.findByEventId(eventId) + if (existing?.status === 'reported') return { status: 'reported', eventId } + if (existing?.status === 'blocked') throw new RemoteDownloadBillingBlockedError() + + const now = new Date() + if (!existing) { + await deps.remoteDownloadUsage.insert({ + orgId: params.orgId, + downloaderId: params.downloaderId, + taskId: params.taskId, + eventId, + unitIndex: params.unitIndex, + unitBytes: params.unitBytes, + creditsPerUnit: params.creditsPerUnit, + now, + }) + } + + const status = await syncRemoteDownloadUsageReport(deps, { + cloudBaseUrl: params.cloudBaseUrl, + report: (await deps.remoteDownloadUsage.findByEventId(eventId))!, + now, + }) + if (status === 'blocked') throw new RemoteDownloadBillingBlockedError() + return { status, eventId } +} + +export async function syncPendingRemoteDownloadUsageReports( + deps: RemoteDownloadUsageDeps, + params: { cloudBaseUrl: string; limit?: number; now?: Date }, +): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> { + const { cloudBaseUrl, limit = 100, now = new Date() } = params + if (!hasFeature('quota_store', await loadBindingState(deps))) + return { attempted: 0, reported: 0, blocked: 0, failed: 0 } + const binding = await deps.licenseBinding.loadActiveLicenseBinding() + if (!binding?.refreshToken || !binding.cloudStoreId) return { attempted: 0, reported: 0, blocked: 0, failed: 0 } + + const reports = await deps.remoteDownloadUsage.listPending(limit) + + const result = { attempted: reports.length, reported: 0, blocked: 0, failed: 0 } + for (const report of reports) { + const status = await syncRemoteDownloadUsageReport(deps, { cloudBaseUrl, report, now }) + result[status] += 1 + } + return result +} + +async function syncRemoteDownloadUsageReport( + deps: RemoteDownloadUsageDeps, + params: { cloudBaseUrl: string; report: RemoteDownloadUsageReportRecord; now: Date }, +): Promise<'reported' | 'blocked' | 'failed'> { + const { cloudBaseUrl, report, now } = params + const binding = await deps.licenseBinding.loadActiveLicenseBinding() + if (!binding?.refreshToken || !binding.cloudStoreId) { + await deps.remoteDownloadUsage.updateStatus(report.eventId, 'skipped_unbound', null, now) + return 'reported' + } + + try { + const client = deps.licensingCloud.createBoundCloudClient(cloudBaseUrl, binding.refreshToken) + const response = await deps.licensingCloud.requestCloudJson( + client.stores[':storeId'].billing['usage-events'].$post({ + param: { storeId: binding.cloudStoreId }, + json: { + resource: 'remote_download', + unit: 'byte', + bytes: report.unitBytes, + eventId: report.eventId, + idempotencyKey: report.eventId, + customerId: report.orgId, + source: 'remote_download', + sourceId: report.taskId, + usageContext: { downloaderId: report.downloaderId }, + pricing: { unitQuantity: report.unitBytes, creditsPerUnit: report.creditsPerUnit }, + } as never, + }), + usageResponseSchema, + ) + if (!response.accepted) throw new Error('cloud_usage_report_rejected') + await deps.remoteDownloadUsage.updateStatus(report.eventId, 'reported', null, now) + return 'reported' + } catch (error) { + const message = error instanceof Error ? error.message : 'cloud_usage_report_failed' + if (message === 'insufficient_credits' || message === 'overage_cap_exceeded') { + await deps.remoteDownloadUsage.updateStatus(report.eventId, 'blocked', message, now) + return 'blocked' + } + await deps.remoteDownloadUsage.updateStatus(report.eventId, 'failed', message, now) + return 'failed' + } +} diff --git a/server/services/save-to-drive.cf-test.ts b/server/usecases/save-to-drive.cf-test.ts similarity index 83% rename from server/services/save-to-drive.cf-test.ts rename to server/usecases/save-to-drive.cf-test.ts index 8939c8b5..45e43798 100644 --- a/server/services/save-to-drive.cf-test.ts +++ b/server/usecases/save-to-drive.cf-test.ts @@ -2,16 +2,41 @@ import { env } from 'cloudflare:workers' import { nanoid } from 'nanoid' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DirType } from '../../shared/constants' +import type { CreateShareInput } from '../../shared/schemas/share' +import { S3Service } from '../adapters/gateways/s3' +import { createActivityRepo } from '../adapters/repos/activity' +import { createMatterRepo } from '../adapters/repos/matter' +import { createQuotaRepo } from '../adapters/repos/quota' +import { createShareRepo } from '../adapters/repos/share' +import { createStorageRepo } from '../adapters/repos/storage' +import { createStorageUsageRepo } from '../adapters/repos/storage-usage' import { matters } from '../db/schema' import { createCloudflarePlatform } from '../platform/cloudflare' -import { S3Service } from './s3' -import { saveShareToDrive } from './save-to-drive' -import { createShare, resolveShareByToken, revokeShareByToken } from './share' +import type { Database } from '../platform/interface' +import { type SaveShareInput, type SaveToDriveDeps, saveShareToDrive as saveShareToDriveUseCase } from './save-to-drive' function buildDb() { return createCloudflarePlatform(env).db } +const createShare = (db: Database, input: CreateShareInput) => createShareRepo(db).create(input) +const resolveShareByToken = (db: Database, token: string) => createShareRepo(db).resolveByToken(token) +const revokeShareByToken = (db: Database, token: string, creatorId: string) => + createShareRepo(db).revokeByToken(token, creatorId) + +function saveToDriveDeps(db: Database): SaveToDriveDeps { + return { + s3: new S3Service(), + storages: createStorageRepo(db), + storageUsage: createStorageUsageRepo(db), + quota: createQuotaRepo(db), + activity: createActivityRepo(db), + share: createShareRepo(db), + matter: createMatterRepo(db), + } +} +const saveShareToDrive = (db: Database, input: SaveShareInput) => saveShareToDriveUseCase(saveToDriveDeps(db), input) + async function seedStorage(db: ReturnType, id: string) { await db.run( `INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at) diff --git a/server/services/save-to-drive.integration.test.ts b/server/usecases/save-to-drive.integration.test.ts similarity index 94% rename from server/services/save-to-drive.integration.test.ts rename to server/usecases/save-to-drive.integration.test.ts index 66a35dc9..b0c8beeb 100644 --- a/server/services/save-to-drive.integration.test.ts +++ b/server/usecases/save-to-drive.integration.test.ts @@ -2,11 +2,43 @@ import { sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DirType } from '../../shared/constants' +import type { CreateShareInput } from '../../shared/schemas/share' +import { S3Service } from '../adapters/gateways/s3.js' +import { createActivityRepo } from '../adapters/repos/activity.js' +import { createMatterRepo } from '../adapters/repos/matter.js' +import { createQuotaRepo } from '../adapters/repos/quota.js' +import { createShareRepo } from '../adapters/repos/share.js' +import { createStorageRepo } from '../adapters/repos/storage.js' +import { createStorageUsageRepo } from '../adapters/repos/storage-usage.js' import { activityEvents, matters, orgQuotaEntitlements, orgQuotas, shares } from '../db/schema' -import { S3Service } from '../services/s3.js' +import type { Database } from '../platform/interface' import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js' -import { computeSourceBytes, isQuotaSufficient, saveShareToDrive } from './save-to-drive.js' -import { createShare, resolveShareByToken } from './share.js' +import type { Matter } from './ports' +import { + type SaveShareInput, + type SaveToDriveDeps, + saveShareToDrive as saveShareToDriveUseCase, +} from './save-to-drive.js' + +const createShare = (db: Database, input: CreateShareInput) => createShareRepo(db).create(input) +const resolveShareByToken = (db: Database, token: string) => createShareRepo(db).resolveByToken(token) +const computeSourceBytes = (db: Database, matter: Matter) => createShareRepo(db).computeSourceBytes(matter) +const isQuotaSufficient = (db: Database, orgId: string, bytes: number) => + createShareRepo(db).hasQuotaForBytes(orgId, bytes) +// The usecase reaches the world through deps; build the subset it needs from the +// same db so reservation/storage/activity side effects land in the test database. +function saveToDriveDeps(db: Database): SaveToDriveDeps { + return { + s3: new S3Service(), + storages: createStorageRepo(db), + storageUsage: createStorageUsageRepo(db), + quota: createQuotaRepo(db), + activity: createActivityRepo(db), + share: createShareRepo(db), + matter: createMatterRepo(db), + } +} +const saveShareToDrive = (db: Database, input: SaveShareInput) => saveShareToDriveUseCase(saveToDriveDeps(db), input) // ─── Test fixtures ──────────────────────────────────────────────────────────── diff --git a/server/services/save-to-drive.ts b/server/usecases/save-to-drive.ts similarity index 51% rename from server/services/save-to-drive.ts rename to server/usecases/save-to-drive.ts index 3a36beea..7901c360 100644 --- a/server/services/save-to-drive.ts +++ b/server/usecases/save-to-drive.ts @@ -1,23 +1,35 @@ -import { and, eq, like, or } from 'drizzle-orm' -import { DirType } from '../../shared/constants' -import { matters } from '../db/schema' -import type { Database } from '../platform/interface' -import { recordActivity } from './activity' -import { hasQuotaForBytes } from './effective-quota' -import type { Matter } from './matter' -import { createMatter } from './matter' -import { buildObjectKey, fileExt } from './path-template' -import { S3Service } from './s3' -import type { Share, ShareResolution } from './share' -import { getStorage, type Storage as S3StorageType, selectStorage } from './storage' +import { DirType } from '@shared/constants' +import { buildObjectKey, fileExt } from '../lib/path-template' +import type { + ActivityRepo, + Matter, + MatterRepo, + QuotaRepo, + S3Gateway, + ShareRepo, + StorageRecord, + StorageRepo, + StorageUsageRepo, +} from './ports' import { withStorageUsageReservation } from './storage-usage' -// ─── Types ──────────────────────────────────────────────────────────────────── +// Pure orchestration: copies a shared matter (file or folder) into another org, +// reserving target-org quota per file via withStorageUsageReservation. Reaches +// the outside world only through deps; matter creation goes through the matter +// repo port. -export type { ShareResolution } +export type SaveToDriveDeps = { + s3: S3Gateway + storages: StorageRepo + storageUsage: StorageUsageRepo + quota: QuotaRepo + activity: ActivityRepo + share: ShareRepo + matter: MatterRepo +} export interface SaveShareInput { - share: Share + share: { id: string } matter: Matter currentUserId: string targetOrgId: string @@ -30,7 +42,6 @@ export interface SaveShareResult { skipped: Array<{ name: string; reason: string }> } -// Activity log entry recorded in the target org for each copied file. interface CopyActivity { action: string metadata: Record @@ -45,52 +56,15 @@ export interface CopyMatterToOrgInput { teamQuotaEnabled?: boolean } -// ─── Internal helpers ───────────────────────────────────────────────────────── - -const s3 = new S3Service() - function buildPath(parent: string, name: string): string { return parent ? `${parent}/${name}` : name } -async function getDirectActiveChildren(db: Database, orgId: string, folderPath: string): Promise { - return db - .select() - .from(matters) - .where(and(eq(matters.orgId, orgId), eq(matters.parent, folderPath), eq(matters.status, 'active'))) -} - -// ─── Quota helpers ──────────────────────────────────────────────────────────── - -export async function computeSourceBytes(db: Database, matter: Matter): Promise { - 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) -} - -export async function isQuotaSufficient(db: Database, orgId: string, bytes: number): Promise { - return hasQuotaForBytes(db, orgId, bytes) -} - -// ─── File copy ──────────────────────────────────────────────────────────────── - async function saveFile( - db: Database, + deps: SaveToDriveDeps, sourceMatter: Matter, - sourceStorage: S3StorageType, - targetStorage: S3StorageType, + sourceStorage: StorageRecord, + targetStorage: StorageRecord, currentUserId: string, targetOrgId: string, targetParent: string, @@ -98,21 +72,20 @@ async function saveFile( teamQuotaEnabled = true, ): Promise { const bytes = sourceMatter.size ?? 0 - const dstKey = buildObjectKey({ uid: currentUserId, orgId: targetOrgId, rawExt: fileExt(sourceMatter.name) }) return withStorageUsageReservation( - db, + { quota: deps.quota, storageUsage: deps.storageUsage }, { orgId: targetOrgId, storageId: targetStorage.id, bytes, teamQuotaEnabled }, async (ctx) => { if (sourceStorage.id === targetStorage.id) { - await s3.copyObject(sourceStorage, sourceMatter.object, targetStorage, dstKey) + await deps.s3.copyObject(sourceStorage, sourceMatter.object, targetStorage, dstKey) } else { - await s3.streamCopy(sourceStorage, sourceMatter.object, targetStorage, dstKey) + await deps.s3.streamCopy(sourceStorage, sourceMatter.object, targetStorage, dstKey) } - ctx.onRollback(() => s3.deleteObject(targetStorage, dstKey)) + ctx.onRollback(() => deps.s3.deleteObject(targetStorage, dstKey)) - const newMatter = await createMatter(db, { + const newMatter = await deps.matter.create({ orgId: targetOrgId, name: sourceMatter.name, type: sourceMatter.type, @@ -125,7 +98,7 @@ async function saveFile( onConflict: 'rename', }) - await recordActivity(db, { + await deps.activity.record({ orgId: targetOrgId, userId: currentUserId, action: activity.action, @@ -140,13 +113,11 @@ async function saveFile( ) } -// ─── Folder recursive copy ──────────────────────────────────────────────────── - async function saveFolderRecursive( - db: Database, + deps: SaveToDriveDeps, sourceFolderMatter: Matter, - sourceStorage: S3StorageType, - targetStorage: S3StorageType, + sourceStorage: StorageRecord, + targetStorage: StorageRecord, currentUserId: string, targetOrgId: string, targetParent: string, @@ -156,7 +127,7 @@ async function saveFolderRecursive( const saved: Matter[] = [] const skipped: Array<{ name: string; reason: string }> = [] - const rootFolder = await createMatter(db, { + const rootFolder = await deps.matter.create({ orgId: targetOrgId, name: sourceFolderMatter.name, type: 'folder', @@ -173,20 +144,19 @@ async function saveFolderRecursive( const sourceRootPath = buildPath(sourceFolderMatter.parent, sourceFolderMatter.name) const targetRootPath = buildPath(targetParent, rootFolder.name) - // BFS: pairs of (source folder path, target folder path) const queue: Array<{ sourcePath: string; targetPath: string }> = [ { sourcePath: sourceRootPath, targetPath: targetRootPath }, ] while (queue.length > 0) { const { sourcePath, targetPath } = queue.shift()! - const children = await getDirectActiveChildren(db, sourceFolderMatter.orgId, sourcePath) + const children = await deps.share.listDirectActiveChildren(sourceFolderMatter.orgId, sourcePath) for (const child of children) { if (child.dirtype === DirType.FILE) { try { const newFile = await saveFile( - db, + deps, child, sourceStorage, targetStorage, @@ -201,7 +171,7 @@ async function saveFolderRecursive( skipped.push({ name: child.name, reason: (e as Error).message }) } } else { - const newFolder = await createMatter(db, { + const newFolder = await deps.matter.create({ orgId: targetOrgId, name: child.name, type: 'folder', @@ -225,28 +195,23 @@ async function saveFolderRecursive( return { saved, skipped } } -// ─── Public API ─────────────────────────────────────────────────────────────── - -// Copy a file or folder (recursively) into another org. Quota is reserved in -// the target org per file; files that fail (e.g. quota) are reported in -// `skipped` rather than failing the whole operation. -export async function copyMatterToOrg(db: Database, input: CopyMatterToOrgInput): Promise { +// Copy a file or folder (recursively) into another org. Quota is reserved in the +// target org per file; files that fail (e.g. quota) are reported in `skipped` +// rather than failing the whole operation. +export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterToOrgInput): Promise { const { sourceMatter, currentUserId, targetOrgId, targetParent, activity, teamQuotaEnabled = true } = input - const sourceStorage = await getStorage(db, sourceMatter.storageId) + const sourceStorage = await deps.storages.get(sourceMatter.storageId) if (!sourceStorage) throw new Error('Source storage not found') - const targetStorage = await selectStorage(db, 'private') - - const src = sourceStorage - const dst = targetStorage + const targetStorage = await deps.storages.select('private') if (sourceMatter.dirtype === DirType.FILE) { const newMatter = await saveFile( - db, + deps, sourceMatter, - src, - dst, + sourceStorage, + targetStorage, currentUserId, targetOrgId, targetParent, @@ -257,10 +222,10 @@ export async function copyMatterToOrg(db: Database, input: CopyMatterToOrgInput) } return saveFolderRecursive( - db, + deps, sourceMatter, - src, - dst, + sourceStorage, + targetStorage, currentUserId, targetOrgId, targetParent, @@ -269,9 +234,9 @@ export async function copyMatterToOrg(db: Database, input: CopyMatterToOrgInput) ) } -export async function saveShareToDrive(db: Database, input: SaveShareInput): Promise { +export async function saveShareToDrive(deps: SaveToDriveDeps, input: SaveShareInput): Promise { const { share, matter: sourceMatter, ...rest } = input - return copyMatterToOrg(db, { + return copyMatterToOrg(deps, { ...rest, sourceMatter, activity: { action: 'save_from_share', metadata: { sourceShareId: share.id } }, diff --git a/server/usecases/share-notification.integration.test.ts b/server/usecases/share-notification.integration.test.ts new file mode 100644 index 00000000..caeefaf3 --- /dev/null +++ b/server/usecases/share-notification.integration.test.ts @@ -0,0 +1,228 @@ +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as authSchema from '../db/auth-schema.js' +import { notifications, systemOptions } from '../db/schema.js' +import { createTestApp } from '../test/setup.js' +import type { ShareNotificationRecipient, ShareNotificationShare } from './ports' +import { dispatchShareCreated } from './share-notification.js' + +type TestCtx = Awaited> +type TestDb = TestCtx['db'] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) { + const id = overrides.id ?? nanoid() + const email = overrides.email ?? `${id}@example.com` + await db.insert(authSchema.user).values({ + id, + name: 'Test User', + email, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }) + return { id, email } +} + +function makeShare(overrides: Partial = {}): ShareNotificationShare { + return { + id: overrides.id ?? nanoid(), + token: overrides.token ?? nanoid(10), + kind: overrides.kind ?? 'landing', + expiresAt: overrides.expiresAt ?? null, + } +} + +async function configureEmail(db: TestDb) { + await db.insert(systemOptions).values([ + { key: 'email_enabled', value: 'true', public: false }, + { key: 'email_provider', value: 'smtp', public: false }, + { key: 'email_from', value: 'no-reply@example.com', public: false }, + { key: 'email_smtp_host', value: 'smtp.example.com', public: false }, + { key: 'email_smtp_port', value: '587', public: false }, + ]) +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('dispatchShareCreated', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + it('inserts a notification row when recipient has recipientUserId', async () => { + const ctx = await createTestApp() + const user = await insertUser(ctx.db) + const share = makeShare() + const recipients: ShareNotificationRecipient[] = [{ recipientUserId: user.id }] + + await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Alice', 'secret.pdf') + + const rows = await ctx.db.select().from(notifications).where(eq(notifications.userId, user.id)) + expect(rows).toHaveLength(1) + expect(rows[0].type).toBe('share_received') + expect(rows[0].title).toContain('Alice') + expect(rows[0].title).toContain('secret.pdf') + expect(rows[0].refType).toBe('share') + expect(rows[0].refId).toBe(share.id) + }) + + it('does not insert notification when recipient has only email (no userId)', async () => { + const ctx = await createTestApp() + const share = makeShare() + const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'someone@example.com' }] + + await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Bob', 'file.txt') + + const rows = await ctx.db.select().from(notifications) + expect(rows).toHaveLength(0) + }) + + it('does not send email and does not throw when email is not configured', async () => { + const ctx = await createTestApp() + const sendSpy = vi.spyOn(ctx.deps.email, 'send') + const share = makeShare() + const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'test@example.com' }] + + // No email config in DB + await expect( + dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Carol', 'report.docx'), + ).resolves.toBeUndefined() + expect(sendSpy).not.toHaveBeenCalled() + }) + + it('sends email when email is configured and recipient has email', async () => { + const ctx = await createTestApp() + const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockResolvedValue(undefined) + + await configureEmail(ctx.db) + + const share = makeShare() + const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'dave@example.com' }] + + await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Eve', 'photo.jpg') + + expect(sendSpy).toHaveBeenCalledOnce() + const callArgs = sendSpy.mock.calls[0] + // send(platform, message) — second arg is the message + expect(callArgs[1].to).toBe('dave@example.com') + expect(callArgs[1].subject).toContain('Eve') + expect(callArgs[1].subject).toContain('photo.jpg') + }) + + it('looks up email from user table when recipient has only recipientUserId and email is configured', async () => { + const ctx = await createTestApp() + const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockResolvedValue(undefined) + + await configureEmail(ctx.db) + + const user = await insertUser(ctx.db, { email: 'frank@example.com' }) + const share = makeShare() + const recipients: ShareNotificationRecipient[] = [{ recipientUserId: user.id }] + + await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Grace', 'budget.xlsx') + + expect(sendSpy).toHaveBeenCalledOnce() + const callArgs = sendSpy.mock.calls[0] + expect(callArgs[1].to).toBe('frank@example.com') + }) + + it('does not throw when email send fails — logs and continues', async () => { + const ctx = await createTestApp() + const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockRejectedValue(new Error('SMTP down')) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await configureEmail(ctx.db) + + const share = makeShare() + const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'victim@example.com' }] + + // Should NOT throw despite email failure + await expect( + dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Sender', 'file.txt'), + ).resolves.toBeUndefined() + expect(sendSpy).toHaveBeenCalledOnce() + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + it('inserts in-app notifications for all recipients that have recipientUserId', async () => { + const ctx = await createTestApp() + const user1 = await insertUser(ctx.db) + const user2 = await insertUser(ctx.db) + const share = makeShare() + + const recipients: ShareNotificationRecipient[] = [ + { recipientUserId: user1.id }, + { recipientUserId: user2.id }, + { recipientEmail: 'no-account@example.com' }, + ] + + await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Hub', 'multi.zip') + + const rows1 = await ctx.db.select().from(notifications).where(eq(notifications.userId, user1.id)) + expect(rows1).toHaveLength(1) + + const rows2 = await ctx.db.select().from(notifications).where(eq(notifications.userId, user2.id)) + expect(rows2).toHaveLength(1) + + // No notification for email-only recipient + const allRows = await ctx.db.select().from(notifications) + expect(allRows).toHaveLength(2) + }) + + it('uses /s/{token} URL for landing shares in notification metadata', async () => { + const ctx = await createTestApp() + const user = await insertUser(ctx.db) + const share = makeShare({ kind: 'landing', token: 'abc123token' }) + + await dispatchShareCreated(ctx.deps, ctx.platform, share, [{ recipientUserId: user.id }], 'Ian', 'landing.pdf') + + const rows = await ctx.db.select().from(notifications).where(eq(notifications.userId, user.id)) + expect(rows).toHaveLength(1) + const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record + expect(metadata.token).toBe('abc123token') + expect(metadata.kind).toBe('landing') + }) + + it('uses /r/{token} URL for direct shares in notification metadata', async () => { + const ctx = await createTestApp() + const user = await insertUser(ctx.db) + const share = makeShare({ kind: 'direct', token: 'directtoken1' }) + + await dispatchShareCreated(ctx.deps, ctx.platform, share, [{ recipientUserId: user.id }], 'Jane', 'direct.mp4') + + const rows = await ctx.db.select().from(notifications).where(eq(notifications.userId, user.id)) + expect(rows).toHaveLength(1) + const metadata = JSON.parse(rows[0].metadata ?? '{}') as Record + expect(metadata.kind).toBe('direct') + }) + + it('includes expiresAt in email body when share has an expiry date', async () => { + const ctx = await createTestApp() + const sendSpy = vi.spyOn(ctx.deps.email, 'send').mockResolvedValue(undefined) + + await configureEmail(ctx.db) + + const expiresAt = new Date('2026-12-31T00:00:00Z') + const share = makeShare({ expiresAt }) + const recipients: ShareNotificationRecipient[] = [{ recipientEmail: 'reader@example.com' }] + + await dispatchShareCreated(ctx.deps, ctx.platform, share, recipients, 'Karl', 'expiring.pdf') + + expect(sendSpy).toHaveBeenCalledOnce() + const emailHtml = sendSpy.mock.calls[0][1].html + expect(emailHtml).toContain('2026-12-31') + }) + + it('handles empty recipients array without errors', async () => { + const ctx = await createTestApp() + const share = makeShare() + + await expect(dispatchShareCreated(ctx.deps, ctx.platform, share, [], 'Leo', 'empty.txt')).resolves.toBeUndefined() + + const rows = await ctx.db.select().from(notifications) + expect(rows).toHaveLength(0) + }) +}) diff --git a/server/services/share-notification.ts b/server/usecases/share-notification.ts similarity index 54% rename from server/services/share-notification.ts rename to server/usecases/share-notification.ts index 034933db..abffecb4 100644 --- a/server/services/share-notification.ts +++ b/server/usecases/share-notification.ts @@ -1,21 +1,25 @@ -import { eq } from 'drizzle-orm' -import { user } from '../db/auth-schema' -import type { Database, Platform } from '../platform/interface' -import { isEmailConfigured, sendEmail } from './email' -import { createNotification } from './notification' -import type { Share } from './share' +import type { Platform } from '../platform/interface' +import type { + EmailGateway, + NotificationRepo, + ShareNotificationRecipient, + ShareNotificationRepo, + ShareNotificationShare, +} from './ports' -async function getUserEmail(db: Database, userId: string): Promise { - const rows = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1) - return rows[0]?.email ?? null +export type ShareNotificationDeps = { + notifications: NotificationRepo + email: EmailGateway + shareNotifications: ShareNotificationRepo } async function sendShareEmail( - source: Database | Platform, + deps: ShareNotificationDeps, + platform: Platform, opts: { to: string; creatorName: string; matterName: string; url: string; expiresAt: Date | null }, ): Promise { const expiryLine = opts.expiresAt ? `

This share expires on ${opts.expiresAt.toISOString().split('T')[0]}.

` : '' - await sendEmail(source, { + await deps.email.send(platform, { to: opts.to, subject: `${opts.creatorName} shared "${opts.matterName}" with you`, html: ` @@ -27,25 +31,20 @@ async function sendShareEmail( }) } -export type RecipientInput = { - recipientUserId?: string | null - recipientEmail?: string | null -} - export async function dispatchShareCreated( - source: Database | Platform, - share: Share, - recipients: RecipientInput[], + deps: ShareNotificationDeps, + platform: Platform, + share: ShareNotificationShare, + recipients: ShareNotificationRecipient[], creatorName: string, matterName: string, ): Promise { - const db = 'db' in source ? source.db : source const shareUrl = share.kind === 'landing' ? `/s/${share.token}` : `/r/${share.token}` - const emailEnabled = await isEmailConfigured(source) + const emailEnabled = await deps.email.isConfigured(platform) for (const r of recipients) { if (r.recipientUserId) { - await createNotification(db, { + await deps.notifications.create({ userId: r.recipientUserId, type: 'share_received', title: `${creatorName} shared "${matterName}" with you`, @@ -56,11 +55,18 @@ export async function dispatchShareCreated( }) } - const email = r.recipientEmail ?? (r.recipientUserId ? await getUserEmail(db, r.recipientUserId) : null) + const email = + r.recipientEmail ?? (r.recipientUserId ? await deps.shareNotifications.getUserEmail(r.recipientUserId) : null) if (email && emailEnabled) { try { - await sendShareEmail(source, { to: email, creatorName, matterName, url: shareUrl, expiresAt: share.expiresAt }) + await sendShareEmail(deps, platform, { + to: email, + creatorName, + matterName, + url: shareUrl, + expiresAt: share.expiresAt, + }) } catch (err) { console.error(`[share-notification] email to ${email} failed:`, err) } diff --git a/server/services/signup-mode-guard.integration.test.ts b/server/usecases/signup-mode.integration.test.ts similarity index 96% rename from server/services/signup-mode-guard.integration.test.ts rename to server/usecases/signup-mode.integration.test.ts index 3e20c7df..4d82b7c9 100644 --- a/server/services/signup-mode-guard.integration.test.ts +++ b/server/usecases/signup-mode.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' +import { createInviteRepo } from '../adapters/repos/invite.js' import * as schema from '../db/schema.js' import { createTestApp, seedProLicense as seedProLicenseRow } from '../test/setup.js' -import { generateInviteCodes } from './invite.js' type TestCtx = Awaited> @@ -67,7 +67,7 @@ describe('open mode (non-Pro instance) — retroactive gate', () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' }) await seedFirstUser(ctx) - 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) }) @@ -90,7 +90,7 @@ describe('invite_only mode (Pro instance)', () => { await seedProLicense(ctx) await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await seedFirstUser(ctx) - 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) }) @@ -111,7 +111,7 @@ describe('invite_only mode (non-Pro instance)', () => { const ctx = await createTestApp() await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' }) await seedFirstUser(ctx) - 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) }) diff --git a/server/services/signup-mode-guard.ts b/server/usecases/signup-mode.ts similarity index 55% rename from server/services/signup-mode-guard.ts rename to server/usecases/signup-mode.ts index 71fe4b7e..ddde7f6a 100644 --- a/server/services/signup-mode-guard.ts +++ b/server/usecases/signup-mode.ts @@ -1,8 +1,9 @@ -import { eq } from 'drizzle-orm' -import { SignupMode } from '../../shared/constants' -import { systemOptions } from '../db/schema' -import { hasFeature, loadBindingState } from '../licensing/has-feature' -import type { Database } from '../platform/interface' +import { SignupMode } from '@shared/constants' +import { hasFeature } from '../domain/licensing' +import { loadBindingState } from './licensing' +import type { LicenseBindingRepo, SystemOptionsRepo } from './ports' + +export type SignupModeDeps = { systemOptions: SystemOptionsRepo; licenseBinding: LicenseBindingRepo } /** * Returns the effective signup mode. @@ -12,17 +13,13 @@ import type { Database } from '../platform/interface' * (invite_only, closed) are returned unchanged. Unknown/empty values retain * the existing default-to-open behaviour and are not subject to the Pro check. */ -export async function getEffectiveSignupMode(db: Database): Promise { - const rows = await db - .select({ value: systemOptions.value }) - .from(systemOptions) - .where(eq(systemOptions.key, 'auth_signup_mode')) - const raw = rows[0]?.value +export async function getEffectiveSignupMode(deps: SignupModeDeps): Promise { + const raw = await deps.systemOptions.getValue('auth_signup_mode') if (raw === SignupMode.INVITE_ONLY || raw === SignupMode.CLOSED) return raw if (raw !== SignupMode.OPEN) return SignupMode.OPEN // unknown/empty → open (existing behaviour) // Stored value is explicitly 'open' — gate behind Pro feature - const state = await loadBindingState(db) + const state = await loadBindingState({ licenseBinding: deps.licenseBinding }) return hasFeature('open_registration', state) ? SignupMode.OPEN : SignupMode.INVITE_ONLY } diff --git a/server/services/site-public-origin.test.ts b/server/usecases/site-public-origin.test.ts similarity index 64% rename from server/services/site-public-origin.test.ts rename to server/usecases/site-public-origin.test.ts index 1a46d165..a17d5460 100644 --- a/server/services/site-public-origin.test.ts +++ b/server/usecases/site-public-origin.test.ts @@ -9,44 +9,44 @@ beforeEach(() => { describe('ensureSitePublicOrigin', () => { it('persists the request origin on first call and reports created', async () => { - const { db } = await createTestApp() + const { deps } = await createTestApp() - const result = await ensureSitePublicOrigin(db, 'https://pan.example.com/api/auth/get-session') + const result = await ensureSitePublicOrigin(deps, 'https://pan.example.com/api/auth/get-session') expect(result).toEqual({ origin: 'https://pan.example.com', created: true }) - expect(await getSitePublicOrigin(db)).toBe('https://pan.example.com') + expect(await getSitePublicOrigin(deps)).toBe('https://pan.example.com') }) it('adopts an existing configured origin instead of the request origin', async () => { - const { db } = await createTestApp() + const { db, deps } = await createTestApp() await db.run(sql` INSERT INTO system_options (key, value, public) VALUES ('site_public_origin', 'https://configured.example.com', 0) `) - const result = await ensureSitePublicOrigin(db, 'https://request.example.com/files') + const result = await ensureSitePublicOrigin(deps, 'https://request.example.com/files') expect(result).toEqual({ origin: 'https://configured.example.com', created: false }) }) it('serves the cached origin without touching the database', async () => { - const { db } = await createTestApp() - await ensureSitePublicOrigin(db, 'https://pan.example.com/files') + const { deps } = await createTestApp() + await ensureSitePublicOrigin(deps, 'https://pan.example.com/files') // Any DB access would throw on a null handle — the cache must answer. - const result = await ensureSitePublicOrigin(null as never, 'https://other.example.com/files') + const result = await ensureSitePublicOrigin({ systemOptions: null as never }, 'https://other.example.com/files') expect(result).toEqual({ origin: 'https://pan.example.com', created: false }) }) it('does not cache when no origin can be determined', async () => { - const { db } = await createTestApp() + const { deps } = await createTestApp() - const first = await ensureSitePublicOrigin(db, 'not-a-url') + const first = await ensureSitePublicOrigin(deps, 'not-a-url') expect(first).toEqual({ origin: null, created: false }) // A later request with a valid URL must still be able to bootstrap. - const second = await ensureSitePublicOrigin(db, 'https://pan.example.com/files') + const second = await ensureSitePublicOrigin(deps, 'https://pan.example.com/files') expect(second).toEqual({ origin: 'https://pan.example.com', created: true }) }) }) diff --git a/server/usecases/site-public-origin.ts b/server/usecases/site-public-origin.ts new file mode 100644 index 00000000..ba11e0d1 --- /dev/null +++ b/server/usecases/site-public-origin.ts @@ -0,0 +1,48 @@ +import { normalizePublicOrigin, originFromRequestUrl, SITE_PUBLIC_ORIGIN_KEY } from '../domain/site-public-origin' +import type { SystemOptionsRepo } from './ports' + +// Resolved origin, cached for the lifetime of the isolate/process. Only the +// settled value is cached — never a pending promise, which on Cloudflare +// Workers would hang any request that awaited it after its creating request +// ended. One worker serves one site, so a single slot is enough; staleness is +// harmless because the middleware only acts when the row is first created. +let cachedOrigin: string | null = null + +export function resetSitePublicOriginCache() { + cachedOrigin = null +} + +export type SitePublicOriginDeps = { systemOptions: SystemOptionsRepo } + +export interface EnsureSitePublicOriginResult { + origin: string | null + created: boolean +} + +export async function getSitePublicOrigin(deps: SitePublicOriginDeps): Promise { + return normalizePublicOrigin(await deps.systemOptions.getValue(SITE_PUBLIC_ORIGIN_KEY)) +} + +export async function ensureSitePublicOrigin( + deps: SitePublicOriginDeps, + requestUrl: string, +): Promise { + if (cachedOrigin) return { origin: cachedOrigin, created: false } + + const existing = await getSitePublicOrigin(deps) + if (existing) { + cachedOrigin = existing + return { origin: existing, created: false } + } + + const origin = originFromRequestUrl(requestUrl) + if (!origin) return { origin: null, created: false } + + // Concurrent first requests may race here; both write the same resolved origin, + // so the re-read below settles on the persisted value either way. + await deps.systemOptions.set(SITE_PUBLIC_ORIGIN_KEY, origin, false) + + const saved = await getSitePublicOrigin(deps) + if (saved) cachedOrigin = saved + return { origin: saved, created: saved === origin } +} diff --git a/server/usecases/storage-usage.ts b/server/usecases/storage-usage.ts new file mode 100644 index 00000000..1acb14f1 --- /dev/null +++ b/server/usecases/storage-usage.ts @@ -0,0 +1,82 @@ +import { + type QuotaRepo, + type ReserveStorageUsageInput, + StorageQuotaExceededError, + type StorageUsageRepo, + type StorageUsageReservation, +} from './ports' + +export type StorageUsageDeps = { quota: QuotaRepo; storageUsage: StorageUsageRepo } + +type RollbackCleanup = () => Promise | void + +// Tracks side effects to undo if a reservation-guarded action throws. +export class StorageUsageMutationContext { + private readonly cleanups: RollbackCleanup[] = [] + + onRollback(cleanup: RollbackCleanup): void { + this.cleanups.push(cleanup) + } + + async rollbackCleanups(): Promise { + for (const cleanup of [...this.cleanups].reverse()) { + await cleanup() + } + } +} + +async function rollbackReservationMutation( + deps: StorageUsageDeps, + reservations: StorageUsageReservation[], + ctx: StorageUsageMutationContext, + originalError: unknown, +): Promise { + let rollbackError: unknown + try { + await ctx.rollbackCleanups() + } catch (error) { + rollbackError = error + } + try { + await deps.storageUsage.rollbackReservations(reservations) + } catch (error) { + rollbackError ??= error + } + if (rollbackError) throw rollbackError + throw originalError +} + +export async function reserveStorageUsage( + deps: StorageUsageDeps, + input: ReserveStorageUsageInput, +): Promise { + if (input.bytes <= 0) return null + const allowed = await deps.quota.incrementUsageIfEffectiveQuotaAllows( + input.orgId, + input.storageId, + input.bytes, + input.teamQuotaEnabled ?? true, + ) + if (!allowed) throw new StorageQuotaExceededError() + return { orgId: input.orgId, storageId: input.storageId, bytes: input.bytes } +} + +export async function withStorageUsageReservation( + deps: StorageUsageDeps, + inputs: ReserveStorageUsageInput | ReserveStorageUsageInput[], + action: (ctx: StorageUsageMutationContext) => Promise, +): Promise { + const reservations: StorageUsageReservation[] = [] + const ctx = new StorageUsageMutationContext() + try { + for (const input of Array.isArray(inputs) ? inputs : [inputs]) { + const reservation = await reserveStorageUsage(deps, input) + if (reservation) reservations.push(reservation) + } + return await action(ctx) + } catch (error) { + return rollbackReservationMutation(deps, reservations, ctx, error) + } +} + +export { StorageQuotaExceededError } from './ports' diff --git a/server/services/team-count-guard.test.ts b/server/usecases/team-count.integration.test.ts similarity index 79% rename from server/services/team-count-guard.test.ts rename to server/usecases/team-count.integration.test.ts index a46839ab..97eae3d5 100644 --- a/server/services/team-count-guard.test.ts +++ b/server/usecases/team-count.integration.test.ts @@ -1,23 +1,28 @@ import { FREE_TEAM_LIMIT } from '@shared/constants' import { nanoid } from 'nanoid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLicenseBindingRepo } from '../adapters/repos/license-binding.js' +import { createMemberCountRepo } from '../adapters/repos/member-count.js' import * as authSchema from '../db/auth-schema.js' import { createTestApp } from '../test/setup.js' -import { checkTeamLimit, countUserOrgs } from './team-count-guard.js' +import { checkTeamLimit } from './team-count.js' // --------------------------------------------------------------------------- // Mock the licensing layer — we test the guard logic, not the license DB reads // --------------------------------------------------------------------------- -vi.mock('../licensing/has-feature', () => ({ - loadBindingState: vi.fn(), - hasFeature: vi.fn(), -})) +vi.mock('./licensing', () => ({ loadBindingState: vi.fn() })) +vi.mock('../domain/licensing', () => ({ hasFeature: vi.fn() })) -import { hasFeature, loadBindingState } from '../licensing/has-feature' +import { hasFeature } from '../domain/licensing' +import { loadBindingState } from './licensing' type TestDb = Awaited>['db'] +function depsFor(db: TestDb) { + return { memberCount: createMemberCountRepo(db), licenseBinding: createLicenseBindingRepo(db) } +} + async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) { const id = overrides.id ?? nanoid() await db.insert(authSchema.user).values({ @@ -53,7 +58,7 @@ async function insertMember(db: TestDb, organizationId: string, userId: string, } // --------------------------------------------------------------------------- -// countUserOrgs +// MemberCountRepo.countUserOrgs // --------------------------------------------------------------------------- describe('countUserOrgs', () => { @@ -61,7 +66,7 @@ describe('countUserOrgs', () => { const { db } = await createTestApp() const userId = await insertUser(db) - const count = await countUserOrgs(db, userId) + const count = await createMemberCountRepo(db).countUserOrgs(userId) expect(count).toBe(0) }) @@ -71,7 +76,7 @@ describe('countUserOrgs', () => { const orgId = await insertOrg(db) await insertMember(db, orgId, userId) - const count = await countUserOrgs(db, userId) + const count = await createMemberCountRepo(db).countUserOrgs(userId) expect(count).toBe(1) }) @@ -83,7 +88,7 @@ describe('countUserOrgs', () => { await insertMember(db, orgA, userId) await insertMember(db, orgB, userId) - const count = await countUserOrgs(db, userId) + const count = await createMemberCountRepo(db).countUserOrgs(userId) expect(count).toBe(2) }) @@ -95,14 +100,13 @@ describe('countUserOrgs', () => { await insertMember(db, orgId, userA) await insertMember(db, orgId, userB) - // userA has 1 membership, unaffected by userB's membership - expect(await countUserOrgs(db, userA)).toBe(1) - expect(await countUserOrgs(db, userB)).toBe(1) + expect(await createMemberCountRepo(db).countUserOrgs(userA)).toBe(1) + expect(await createMemberCountRepo(db).countUserOrgs(userB)).toBe(1) }) it('returns 0 for a user id that has no rows', async () => { const { db } = await createTestApp() - const count = await countUserOrgs(db, 'nonexistent-user') + const count = await createMemberCountRepo(db).countUserOrgs('nonexistent-user') expect(count).toBe(0) }) }) @@ -125,7 +129,7 @@ describe('checkTeamLimit', () => { const { db } = await createTestApp() const userId = await insertUser(db) - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.limit).toBe(FREE_TEAM_LIMIT) expect(result.limit).toBe(2) }) @@ -134,7 +138,7 @@ describe('checkTeamLimit', () => { const { db } = await createTestApp() const userId = await insertUser(db) - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.allowed).toBe(true) expect(result.count).toBe(0) }) @@ -145,7 +149,7 @@ describe('checkTeamLimit', () => { const orgA = await insertOrg(db) await insertMember(db, orgA, userId) - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.allowed).toBe(true) expect(result.count).toBe(1) }) @@ -158,7 +162,7 @@ describe('checkTeamLimit', () => { await insertMember(db, orgA, userId) await insertMember(db, orgB, userId) - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.allowed).toBe(false) expect(result.count).toBe(2) }) @@ -171,7 +175,7 @@ describe('checkTeamLimit', () => { await insertMember(db, orgId, userId) } - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.allowed).toBe(false) expect(result.count).toBe(3) }) @@ -184,7 +188,7 @@ describe('checkTeamLimit', () => { await insertMember(db, orgId, userId) } - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.allowed).toBe(false) expect(result.count).toBe(4) }) @@ -198,7 +202,7 @@ describe('checkTeamLimit', () => { await insertMember(db, orgId, userId) } - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.allowed).toBe(true) expect(result.count).toBe(2) }) @@ -212,18 +216,18 @@ describe('checkTeamLimit', () => { await insertMember(db, orgId, userId) } - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.allowed).toBe(true) expect(result.count).toBe(10) }) - it('calls loadBindingState with the db to determine licensing state', async () => { + it('calls loadBindingState to determine licensing state', async () => { const { db } = await createTestApp() const userId = await insertUser(db) - await checkTeamLimit(db, userId) + await checkTeamLimit(depsFor(db), userId) - expect(loadBindingState).toHaveBeenCalledWith(db) + expect(loadBindingState).toHaveBeenCalled() }) it('calls hasFeature with teams_unlimited and the binding state', async () => { @@ -232,7 +236,7 @@ describe('checkTeamLimit', () => { const { db } = await createTestApp() const userId = await insertUser(db) - await checkTeamLimit(db, userId) + await checkTeamLimit(depsFor(db), userId) expect(hasFeature).toHaveBeenCalledWith('teams_unlimited', mockState) }) @@ -243,7 +247,7 @@ describe('checkTeamLimit', () => { const orgId = await insertOrg(db) await insertMember(db, orgId, userId) - const result = await checkTeamLimit(db, userId) + const result = await checkTeamLimit(depsFor(db), userId) expect(result.count).toBe(1) }) }) diff --git a/server/usecases/team-count.ts b/server/usecases/team-count.ts new file mode 100644 index 00000000..07d68c81 --- /dev/null +++ b/server/usecases/team-count.ts @@ -0,0 +1,18 @@ +import { FREE_TEAM_LIMIT } from '@shared/constants' +import { hasFeature } from '../domain/licensing' +import { loadBindingState } from './licensing' +import type { LicenseBindingRepo, MemberCountRepo } from './ports' + +export type TeamCountDeps = { memberCount: MemberCountRepo; licenseBinding: LicenseBindingRepo } + +export async function checkTeamLimit( + deps: TeamCountDeps, + userId: string, +): Promise<{ allowed: boolean; count: number; limit: number }> { + const [count, state] = await Promise.all([ + deps.memberCount.countUserOrgs(userId), + loadBindingState({ licenseBinding: deps.licenseBinding }), + ]) + const unlimited = hasFeature('teams_unlimited', state) + return { allowed: unlimited || count < FREE_TEAM_LIMIT, count, limit: FREE_TEAM_LIMIT } +} diff --git a/server/services/trash-retention.integration.test.ts b/server/usecases/trash-retention.integration.test.ts similarity index 90% rename from server/services/trash-retention.integration.test.ts rename to server/usecases/trash-retention.integration.test.ts index 84211129..c501696e 100644 --- a/server/services/trash-retention.integration.test.ts +++ b/server/usecases/trash-retention.integration.test.ts @@ -1,13 +1,17 @@ import { sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { S3Service } from '../adapters/gateways/s3.js' +import { createMatterRepo } from '../adapters/repos/matter.js' import { createTestApp } from '../test/setup.js' -import { getMatter } from './matter.js' -import { S3Service } from './s3.js' import { DEFAULT_TRASH_RETENTION_DAYS, purgeExpiredTrash, resolveTrashRetentionDays } from './trash-retention.js' type TestDb = Awaited>['db'] +function getMatter(db: TestDb, id: string, orgId: string) { + return createMatterRepo(db).get(id, orgId) +} + const DAY_MS = 24 * 60 * 60 * 1000 beforeEach(() => { @@ -67,7 +71,7 @@ describe('resolveTrashRetentionDays', () => { describe('purgeExpiredTrash', () => { it('purges trash older than the window and reclaims quota, keeping recent trash and active files', async () => { - const { db } = await createTestApp() + const { db, deps } = await createTestApp() await insertStorage(db) const orgId = nanoid() await insertOrg(db, orgId) @@ -76,7 +80,7 @@ describe('purgeExpiredTrash', () => { await insertFile(db, orgId, { id: 'recent', size: 200, status: 'trashed', trashedAt: now - 5 * DAY_MS }) await insertFile(db, orgId, { id: 'active', size: 300, status: 'active' }) - const purged = await purgeExpiredTrash(db, 30, now) + const purged = await purgeExpiredTrash(deps, 30, now) expect(purged).toBe(1) expect(await getMatter(db, 'old', orgId)).toBeNull() @@ -88,13 +92,13 @@ describe('purgeExpiredTrash', () => { }) it('is a no-op when retention is 0 (disabled)', async () => { - const { db } = await createTestApp() + const { db, deps } = await createTestApp() await insertStorage(db) const orgId = nanoid() await insertOrg(db, orgId) await insertFile(db, orgId, { id: 'old', size: 100, status: 'trashed', trashedAt: Date.now() - 400 * DAY_MS }) - const purged = await purgeExpiredTrash(db, 0) + const purged = await purgeExpiredTrash(deps, 0) expect(purged).toBe(0) expect(await getMatter(db, 'old', orgId)).not.toBeNull() diff --git a/server/services/trash-retention.ts b/server/usecases/trash-retention.ts similarity index 64% rename from server/services/trash-retention.ts rename to server/usecases/trash-retention.ts index b0b86d54..65a6413d 100644 --- a/server/services/trash-retention.ts +++ b/server/usecases/trash-retention.ts @@ -1,6 +1,4 @@ -import type { Database } from '../platform/interface' -import { collectForPurge, listOrgIdsWithExpiredTrash, listTrashedRoots } from './matter' -import { purgeRecursively } from './purge' +import { type PurgeDeps, purgeRecursively } from './purge' const DAY_MS = 24 * 60 * 60 * 1000 export const DEFAULT_TRASH_RETENTION_DAYS = 30 @@ -18,19 +16,19 @@ export function resolveTrashRetentionDays(raw: string | undefined): number { * reclaiming their quota. Retention of 0 disables auto-purge. Runs subtree at a * time via the same purge path as emptying the trash manually. */ -export async function purgeExpiredTrash(db: Database, retentionDays: number, now = Date.now()): Promise { +export async function purgeExpiredTrash(deps: PurgeDeps, retentionDays: number, now = Date.now()): Promise { if (retentionDays <= 0) return 0 const cutoff = now - retentionDays * DAY_MS - const orgIds = await listOrgIdsWithExpiredTrash(db, cutoff) + const orgIds = await deps.matter.listOrgIdsWithExpiredTrash(cutoff) let purged = 0 for (const orgId of orgIds) { - const roots = await listTrashedRoots(db, orgId) + const roots = await deps.matter.listTrashedRoots(orgId) for (const root of roots) { if ((root.trashedAt ?? 0) >= cutoff) continue - const matters = await collectForPurge(db, orgId, root.id) + const matters = await deps.matter.collectForPurge(orgId, root.id) if (!matters) continue - purged += await purgeRecursively(db, orgId, matters) + purged += await purgeRecursively(deps, orgId, matters) } } return purged diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 00000000..aaf76ddb --- /dev/null +++ b/spec/README.md @@ -0,0 +1,49 @@ +# Product specs + +Behaviour-first product specs in Gherkin `.feature` files. This directory is the +source of truth for **what ZPan does**, independent of implementation. There is +**no Cucumber runner** — the `.feature` files are documentation, and tests trace +back to scenarios by id. + +## Convention + +- One `.feature` file per capability (`storages.feature`, `site-invitations.feature`, …). +- Each scenario carries two tags: the **id** `@/` and the **layer** + that proves it (`@domain` / `@usecase` / `@web` / `@api` / `@e2e`): + + ```gherkin + @storages/create-records-activity @api + Scenario: Creating a storage records an audit activity + Given an authenticated admin + When they create a storage + Then a storage_create activity is recorded + ``` + +- The id never changes once written (rename = new id). +- Verify each scenario at the **cheapest layer that can prove it.** In this repo most + land at `@api` (the workerd + real-D1 `*.integration.test.ts` / `*.cf-test.ts` + flows through `app.fetch`) or `@web` (jsdom + MSW). Reserve `@e2e` for the Playwright + cross-stack journeys. Pure rules sit at `@domain` / `@usecase`. + +## Traceability + +Each scenario's home test carries `[spec: ]` in its name: + +```ts +it('records a storage_create activity [spec: storages/create-records-activity]', …) +``` + +`pnpm lint:spec` (wired into CI) enforces the link both ways: every scenario id must +have a referencing test, and every `[spec: id]` breadcrumb must match a real scenario. + +## Status + +Specs are authored capability-by-capability alongside the clean-architecture +migration (see `docs/clean-arch-migration.md`). Migrated capabilities are specced +and traced here; the rest land as their slices migrate. + +## Escalation + +If a non-technical audience ever needs to *run* the Gherkin, wire `playwright-bdd` +(compiles `.feature` → Playwright). These are real `.feature` files, so that step is +drop-in — no rewrite. diff --git a/spec/announcements.feature b/spec/announcements.feature new file mode 100644 index 00000000..26a42633 --- /dev/null +++ b/spec/announcements.feature @@ -0,0 +1,38 @@ +Feature: Announcements + Admins publish site announcements (a licensed feature); users read the active ones. + + @announcements/admin-only @api + Scenario: Only admins manage announcements + Given an authenticated non-admin user + When they call the admin announcements API + Then the API responds 403 + + @announcements/crud @api + Scenario: Admins create, list, update and delete announcements + Given an authenticated admin with the announcements feature + When they create, list, update and delete an announcement + Then each operation succeeds + + @announcements/user-active @api + Scenario: Users see active announcements + Given published announcements + When a user lists announcements with scope active + Then only published announcements are returned + + @announcements/archived-history @api + Scenario: Archived announcements stay in history but not the active list + Given an archived announcement + When the user active list and the history are read + Then it appears in history but not in the active list + + @announcements/no-drafts @api + Scenario: Draft announcements never leak to users + Given a draft announcement + When a user reads the announcement history + Then drafts are excluded + + @announcements/pagination-validation @api + Scenario: Invalid pagination is rejected + Given an authenticated user + When they request announcements with invalid pagination values + Then the API rejects the request diff --git a/spec/audit.feature b/spec/audit.feature new file mode 100644 index 00000000..2a1492fb --- /dev/null +++ b/spec/audit.feature @@ -0,0 +1,69 @@ +Feature: Audit log + Admins review a chronological audit log of activities across the orgs they + administer. The log is a licensed feature, filterable and paginated. + + @audit/auth-required @api + Scenario: The audit log requires authentication + Given an unauthenticated request + When it calls the audit log API + Then the API responds 401 + + @audit/admin-only @api + Scenario: Non-admins cannot read the audit log + Given an authenticated non-admin user + When they call the audit log API + Then the API responds 403 + + @audit/feature-gated @api + Scenario: The audit log requires the audit_log feature + Given an admin whose instance lacks the audit_log feature + When they read the audit log + Then the API responds 402 feature_not_available + + @audit/empty @api + Scenario: An instance with no activity has an empty log + Given no recorded events + When an admin reads the audit log + Then an empty list is returned + + @audit/list-newest-first @api + Scenario: Events are listed newest first + Given recorded events across multiple orgs + When an admin reads the audit log + Then events are returned newest first + + @audit/filter-org @api + Scenario: Events can be filtered by org + Given recorded events in several orgs + When an admin filters by orgId + Then only that org's events are returned + + @audit/filter-user @api + Scenario: Events can be filtered by actor + Given recorded events by several users + When an admin filters by userId + Then only that user's events are returned + + @audit/filter-action @api + Scenario: Events can be filtered by action + Given recorded events of several action types + When an admin filters by action + Then only events of that action are returned + + @audit/filter-target-type @api + Scenario: Events can be filtered by target type + Given recorded events on several target types + When an admin filters by targetType + Then only events on that target type are returned + + @audit/pagination @api + Scenario: The audit log paginates + Given more events than one page + When an admin requests a page + Then the correct page and pageSize are returned + + @audit/actor-info @api + Scenario: Events carry actor and org display info + Given a recorded event + When an admin reads the audit log + Then each item includes the actor display info and org name diff --git a/spec/auth-providers.feature b/spec/auth-providers.feature new file mode 100644 index 00000000..d5b47553 --- /dev/null +++ b/spec/auth-providers.feature @@ -0,0 +1,100 @@ +Feature: Auth providers + Admins configure social/OIDC login providers. The public list exposes only + enabled providers without secrets; admin reads mask the client secret. The free + plan caps the provider count. + + @auth-providers/public-enabled-only @api + Scenario: The public list shows only enabled providers + Given a mix of enabled and disabled providers + When the public provider list is requested + Then only enabled providers are returned + + @auth-providers/public-no-secret @api + Scenario: The public list never exposes secrets + Given a configured provider + When the public list is requested + Then no client secret is included + + @auth-providers/metadata @api + Scenario: Providers carry display name and icon + Given a known provider + When the public list is requested + Then its display name and icon come from provider metadata + + @auth-providers/oidc-fallback @api + Scenario: Unknown OIDC providers fall back to their id + Given an unknown OIDC provider + When the public list is requested + Then the providerId is used as name and icon + + @auth-providers/admin-only @api + Scenario: Only admins manage providers + Given a non-admin user + When they call the admin providers API + Then the API responds 403 + + @auth-providers/admin-list-all @api + Scenario: Admins see all providers including disabled + Given enabled and disabled providers + When an admin lists them + Then all configs are returned + + @auth-providers/mask-secret @api + Scenario: Admin reads mask the client secret + Given a provider with a client secret + When an admin reads it + Then only the last four characters are visible + + @auth-providers/mask-short-secret @api + Scenario: Short secrets are fully masked + Given a provider with a short secret + When an admin reads it + Then the secret is entirely masked + + @auth-providers/create-builtin @api + Scenario: Admins create a built-in provider + Given an admin + When they create a built-in provider + Then it is created with its secret masked in the response + + @auth-providers/create-oidc @api + Scenario: Admins create an OIDC provider + Given an admin + When they create an OIDC provider with a discovery URL + Then it is created + + @auth-providers/free-limit @api + Scenario: The free plan caps providers + Given one provider on the free plan + When an admin creates a second + Then the API responds 402 + + @auth-providers/unlimited-entitlement @api + Scenario: The unlimited entitlement lifts the cap + Given the social_login_unlimited entitlement + When an admin creates additional providers + Then they are allowed + + @auth-providers/update-not-limited @api + Scenario: Updating the only provider is not capped + Given a single provider on the free plan + When an admin updates it + Then the free limit does not block the update + + @auth-providers/update @api + Scenario: Admins update a provider + Given an existing provider + When an admin PUTs it again + Then it is updated + + @auth-providers/unknown-builtin @api + Scenario: Unknown built-in ids are rejected + Given an admin + When they create an unknown built-in provider id + Then the API responds 400 + + @auth-providers/oidc-missing-discovery @api + Scenario: OIDC without a discovery URL is rejected + Given an admin + When they create an OIDC provider with no discoveryUrl + Then the API responds 400 diff --git a/spec/auth-username.feature b/spec/auth-username.feature new file mode 100644 index 00000000..ba5c94da --- /dev/null +++ b/spec/auth-username.feature @@ -0,0 +1,27 @@ +Feature: Username sign-up + Users sign up with an optional username. When omitted, a username is generated + from the email prefix; usernames are unique. + + @auth-username/signup-with-username @api + Scenario: Sign-up stores a provided username + Given a sign-up with a username + When the account is created + Then the username is stored on the user record + + @auth-username/signup-generates-username @api + Scenario: Sign-up generates a username when omitted + Given a sign-up without a username + When the account is created + Then a username is generated from the email prefix + + @auth-username/duplicate-rejected @api + Scenario: Duplicate usernames are rejected + Given an existing username + When a sign-up reuses it + Then a non-200 response is returned + + @auth-username/distinct-usernames @api + Scenario: Distinct usernames register independently + Given two sign-ups with different usernames + When both are created + Then both succeed diff --git a/spec/avatar.feature b/spec/avatar.feature new file mode 100644 index 00000000..64303909 --- /dev/null +++ b/spec/avatar.feature @@ -0,0 +1,63 @@ +Feature: Avatar + Authenticated users upload a personal avatar image, stored on public S3 and + surfaced as a URL on their profile. Uploads are validated and idempotent. + + @avatar/auth-required @api + Scenario: Uploading an avatar requires authentication + Given an unauthenticated request + When it uploads an avatar + Then the API responds 401 + + @avatar/multipart-required @api + Scenario: Avatar upload must be multipart + Given an authenticated user + When they upload with a non-multipart content type + Then the API responds 415 + + @avatar/file-required @api + Scenario: Avatar upload must include a file + Given an authenticated user + When they submit with no file field + Then the API responds 400 + + @avatar/mime-validated @api + Scenario: Avatars must be a supported image type + Given an authenticated user + When they upload a non PNG/JPG/WebP file + Then the API responds 400 + + @avatar/size-limit @api + Scenario: Avatars are size-limited + Given an authenticated user + When they upload a file larger than 2 MiB + Then the API responds 413 + + @avatar/needs-storage @api + Scenario: Avatar upload needs a public storage + Given no public storage is configured + When an authenticated user uploads an avatar + Then the API responds 503 + + @avatar/upload @api + Scenario: A valid avatar is stored and returned + Given an authenticated user and a public storage + When they upload a valid image + Then it is stored to S3, recorded on the user, and its URL is returned + + @avatar/idempotent @api + Scenario: Re-uploading the same type returns the same URL + Given a user who already has an avatar + When they re-upload with the same mime type + Then the same URL is returned + + @avatar/delete @api + Scenario: A user clears their avatar + Given a user with an avatar + When they delete it + Then the image is cleared and all variants are removed from S3 + + @avatar/delete-no-storage @api + Scenario: Clearing an avatar succeeds without storage + Given a user whose avatar storage is gone + When they delete their avatar + Then it succeeds and S3 cleanup is skipped diff --git a/spec/background-jobs.feature b/spec/background-jobs.feature new file mode 100644 index 00000000..8cf34b38 --- /dev/null +++ b/spec/background-jobs.feature @@ -0,0 +1,58 @@ +Feature: Background jobs + Long-running work (e.g. building download archives) runs as background jobs. + Jobs are created via the API, dispatched to a queue, completed by a consumer, + and listed/cancelled/retried by their owning org. + + @background-jobs/create-and-complete @api + Scenario: A job is created and completed + Given an authenticated user + When they create an archive job + Then the job is created and completed after the response + + @background-jobs/queue-dispatch @api + Scenario: Jobs dispatch to the queue and a consumer completes them + Given a queue binding is configured + When an archive job is created + Then it is dispatched to the queue and the consumer completes it + + @background-jobs/missing-target @api + Scenario: A job for a missing target folder fails + Given an explicit target folder that does not exist + When an archive job is created + Then a failed archive job is returned + + @background-jobs/target-is-file @api + Scenario: A job whose target is a file fails + Given an explicit target folder that points to a file + When an archive job is created + Then a failed archive job is returned + + @background-jobs/list-filter @api + Scenario: Jobs are listed with filters and pagination + Given an org's jobs of various status and type + When they are listed with filters + Then matching jobs are returned paginated + + @background-jobs/cross-org-guard @api + Scenario: Jobs are isolated across orgs + Given a job in another org + When a user requests its detail + Then access is rejected + + @background-jobs/cancel @api + Scenario: Only queued or running jobs can be cancelled + Given jobs in various states + When a cancel is requested + Then only queued or running jobs are cancelled + + @background-jobs/retry @api + Scenario: Only failed retryable jobs are retried + Given a failed retryable job + When a retry is requested + Then it is retried without hiding the original failure + + @background-jobs/error-surfacing @api + Scenario: Non-domain errors surface at the route boundary + Given a job whose processing throws a non-domain error + When it is run + Then the error surfaces at the route boundary diff --git a/spec/branding.feature b/spec/branding.feature new file mode 100644 index 00000000..52aeca6c --- /dev/null +++ b/spec/branding.feature @@ -0,0 +1,105 @@ +Feature: Branding + Pro instances customize their wordmark, theme colors, and logo (white-label). + The public branding endpoint serves the active look without authentication. + + @branding/defaults @api + Scenario: Unconfigured branding returns defaults + Given no branding is configured + When the public branding is requested + Then default branding is returned + + @branding/public @api + Scenario: Branding is public + Given configured branding + When it is requested without authentication + Then it is still returned + + @branding/stored-values @api + Scenario: Stored branding is served + Given stored branding values + When the public branding is requested + Then the stored values are returned + + @branding/custom-theme @api + Scenario: Custom theme colors are served + Given stored custom theme colors + When the public branding is requested + Then the custom theme values are returned + + @branding/white-label-gated @api + Scenario: White-label requires Pro + Given an instance without the white_label feature + When an admin updates branding + Then the API responds 402 + + @branding/multipart-required @api + Scenario: Branding updates must be multipart + Given an admin with Pro + When they PUT a non-multipart body + Then the API responds 415 + + @branding/wordmark-length @api + Scenario: Wordmark text is length-limited + Given an admin with Pro + When they submit a wordmark longer than 24 chars + Then the API responds 422 + + @branding/save-text @api + Scenario: Wordmark and powered-by settings are saved + Given an admin with Pro + When they save wordmark text and hide-powered-by + Then the settings are persisted + + @branding/builtin-theme @api + Scenario: A built-in theme can be selected + Given an admin with Pro + When they select a built-in theme + Then the theme is saved + + @branding/save-custom-theme @api + Scenario: Valid custom theme colors are saved + Given an admin with Pro + When they save valid custom colors + Then the colors are persisted + + @branding/invalid-colors @api + Scenario: Invalid custom colors are rejected + Given an admin with Pro + When they submit invalid custom colors + Then the API responds 422 and the stored theme is unchanged + + @branding/logo-upload @api + Scenario: A logo is uploaded to S3 + Given an admin with Pro and a public storage + When they upload a valid logo + Then it is stored to S3 and its URL recorded + + @branding/logo-mime @api + Scenario: Logo type is validated + Given an admin with Pro + When they upload an invalid logo type + Then the API responds 400 + + @branding/logo-size @api + Scenario: Logo size is limited + Given an admin with Pro + When they upload a logo larger than 2MB + Then the API responds 413 + + @branding/logo-needs-storage @api + Scenario: Logo upload needs a public storage + Given no public storage + When an admin uploads a logo + Then the API responds 503 + + @branding/admin-only @api + Scenario: Only admins update branding + Given a non-admin user + When they update branding + Then the API responds 403 + + @branding/reset-field @api + Scenario: A branding field can be reset + Given stored branding + When an admin resets a text field + Then the field is cleared diff --git a/spec/download-tasks.feature b/spec/download-tasks.feature new file mode 100644 index 00000000..aa395a07 --- /dev/null +++ b/spec/download-tasks.feature @@ -0,0 +1,142 @@ +Feature: Remote download tasks + Users queue remote downloads (HTTP/magnet) that external downloader agents claim + via device login, run, and upload back through the standard object-upload API. + Tasks are assigned by heartbeat/capacity; the free plan caps downloader count. + + @download-tasks/register-downloader @api + Scenario: A downloader registers via device login + Given a device-login flow + When a downloader registers + Then it is registered through BetterAuth device login + + @download-tasks/ssrf-guard @api + Scenario: Internal-host source URLs are rejected + Given a source URL targeting an internal host + When a download task is created + Then it is rejected + + @download-tasks/magnet-validation @api + Scenario: Non-magnet magnet tasks are rejected + Given a magnet task whose URI is not a magnet link + When it is created + Then it is rejected + + @download-tasks/delete-downloader-requeues @api + Scenario: Deleting a downloader requeues its tasks + Given a downloader with unfinished tasks + When it is deleted + Then its unfinished tasks return to the queue + + @download-tasks/stale-no-assign @api + Scenario: Stale downloaders get no new tasks + Given a downloader with a stale heartbeat + When tasks are assigned + Then it receives no new tasks + + @download-tasks/capacity-queue @api + Scenario: Tasks stay queued when downloaders are full + Given matching downloaders at capacity + When tasks await assignment + Then they remain queued + + @download-tasks/stale-offline @api + Scenario: Stale downloaders show offline + Given a downloader with a stale heartbeat + When an admin lists downloaders + Then it is reported offline + + @download-tasks/reassign-on-heartbeat @api + Scenario: Live heartbeat reassigns stale tasks + Given unfinished tasks on a stale downloader + When a live heartbeat arrives + Then the tasks are reassigned + + @download-tasks/upload-flow @api + Scenario: A completed remote download uploads via the object API + Given an assigned task + When the downloader uploads the result + Then it flows through the standard object upload API + + @download-tasks/cloud-usage-idempotency @api + Scenario: Cloud usage ids differ from local idempotency keys + Given a remote download usage event + When it is recorded + Then Cloud usage event ids may differ from local idempotency keys + + @download-tasks/runtime-reports @api + Scenario: Runtime reports snapshot while progress stays patchable + Given a running downloader + When it reports runtime state + Then reports are stored as snapshots and progress remains patchable + + @download-tasks/upload-session-failure @api + Scenario: Upload session creation failures are surfaced + Given multipart upload session creation fails + When the downloader uploads + Then the storage failure details are returned + + @download-tasks/upload-completion-failure @api + Scenario: Upload completion failures are surfaced + Given multipart upload completion fails + When the downloader completes upload + Then the storage failure details are returned + + @download-tasks/normalize-target @api + Scenario: Target folder paths are normalized + Given a download task with a target folder + When it is created + Then the target folder path is normalized + + @download-tasks/user-actions @api + Scenario: User actions reach the downloader via polling + Given a running task + When the user submits an action + Then it is delivered through the downloader polling state + + @download-tasks/recover-interrupted @api + Scenario: Downloaders recover interrupted tasks but not user-paused ones + Given interrupted and user-paused tasks + When the assigned downloader recovers + Then interrupted tasks resume but user-paused tasks do not + + @download-tasks/checkpoint-on-retry @api + Scenario: Upload retries preserve the download checkpoint + Given an upload failure after download completed + When the upload is retried + Then the completed download checkpoint is preserved + + @download-tasks/transitional-actions @api + Scenario: Pause and cancel use transitional states + Given a downloading task + When it is paused or cancelled + Then transitional states are used + + @download-tasks/reject-invalid-pause @api + Scenario: Pause is rejected for billing-paused and uploading tasks + Given a billing-paused or uploading task + When pause is requested + Then it is rejected + + @download-tasks/reject-invalid-action @api + Scenario: Invalid task actions are rejected + Given a task + When an invalid action is requested + Then it is rejected + + @download-tasks/sort-filter @api + Scenario: Tasks sort and filter server-side + Given many tasks + When they are listed with sort and filter + Then the server returns them sorted and filtered + + @download-tasks/free-limit @api + Scenario: The free plan caps downloaders + Given one downloader on the free plan + When a second registers + Then the API responds 402 + + @download-tasks/unlimited-entitlement @api + Scenario: The unlimited entitlement lifts the downloader cap + Given the downloaders_unlimited entitlement + When additional downloaders register + Then they are allowed diff --git a/spec/email-config.feature b/spec/email-config.feature new file mode 100644 index 00000000..c54ddf8a --- /dev/null +++ b/spec/email-config.feature @@ -0,0 +1,99 @@ +Feature: Email configuration + Admins configure the outbound email provider (SMTP, HTTP API, or Cloudflare). + Secrets are masked on read; a test endpoint verifies delivery. + + @email-config/auth-required @api + Scenario: Reading email config requires authentication + Given an unauthenticated request + When it reads the email config + Then the API responds 401 + + @email-config/admin-only @api + Scenario: Only admins read email config + Given a non-admin user + When they read the email config + Then the API responds 403 + + @email-config/empty-state @api + Scenario: No config returns a disabled empty state + Given no email config exists + When an admin reads it + Then a disabled empty state is returned + + @email-config/incomplete-provider @api + Scenario: Enabled but incomplete config reports a null provider + Given email is enabled but sender/provider are incomplete + When an admin reads it + Then it returns enabled with a null provider + + @email-config/mask-smtp @api + Scenario: SMTP secrets are masked on read + Given a saved SMTP config + When an admin reads it + Then the SMTP secrets are masked + + @email-config/mask-http @api + Scenario: HTTP-provider secrets are masked on read + Given a saved HTTP-provider config + When an admin reads it + Then the secrets are masked + + @email-config/save-smtp @api + Scenario: SMTP config is saved + Given an admin + When they save an SMTP config + Then it succeeds and persists + + @email-config/save-http @api + Scenario: HTTP-provider config is saved + Given an admin + When they save an HTTP-provider config + Then it succeeds and persists + + @email-config/save-cloudflare @api + Scenario: Cloudflare config is saved + Given an admin + When they save a Cloudflare config + Then it succeeds and persists + + @email-config/invalid-provider @api + Scenario: An invalid provider value is rejected + Given an admin + When they save an invalid provider value + Then the API responds 400 + + @email-config/invalid-from @api + Scenario: An invalid from-address is rejected + Given an admin + When they save an invalid from email + Then the API responds 400 + + @email-config/update @api + Scenario: Saving again updates the config + Given an existing email config + When an admin PUTs it again + Then the config is updated + + @email-config/persist-disabled @api + Scenario: Disabled state persists even with provider config + Given a provider config and email disabled + When an admin saves it + Then the disabled state persists + + @email-config/test-success @api + Scenario: The test endpoint reports success + Given a working email config + When an admin sends a test email + Then it reports success + + @email-config/test-failure @api + Scenario: The test endpoint reports a send failure + Given an email config whose send fails + When an admin sends a test email + Then the API responds 400 with the error + + @email-config/test-no-config @api + Scenario: Testing with no config fails + Given no email config + When an admin sends a test email + Then the API responds 400 diff --git a/spec/events.feature b/spec/events.feature new file mode 100644 index 00000000..c87dd2d9 --- /dev/null +++ b/spec/events.feature @@ -0,0 +1,27 @@ +Feature: Event stream + The client subscribes to a single authenticated server-sent event stream that + pushes background-job and notification updates, replacing per-resource polling. + + @events/auth-required @api + Scenario: The event stream requires authentication + Given an unauthenticated request + When it opens the event stream + Then the API responds 401 + + @events/stream @api + Scenario: The stream pushes jobs and notifications + Given an authenticated user + When they open the event stream + Then job and notification events are streamed to them + + @events/abort @api + Scenario: Aborting the request closes the stream + Given an open event stream + When the request is aborted + Then the stream is closed + + @events/error-event @api + Scenario: A failing query surfaces as an error event + Given an open event stream + When a domain query fails + Then an error event is emitted diff --git a/spec/health.feature b/spec/health.feature new file mode 100644 index 00000000..470fa663 --- /dev/null +++ b/spec/health.feature @@ -0,0 +1,8 @@ +Feature: Health + The service exposes an unauthenticated health endpoint for liveness checks. + + @health/ok @api + Scenario: The health endpoint reports liveness + Given a running instance + When the health endpoint is called + Then it responds ok diff --git a/spec/image-hosting-config.feature b/spec/image-hosting-config.feature new file mode 100644 index 00000000..f5aaac14 --- /dev/null +++ b/spec/image-hosting-config.feature @@ -0,0 +1,106 @@ +Feature: Image hosting configuration + Org admins enable image hosting and optionally bind a custom domain. Reads are + open to members; writes are admin-only. A custom domain is verified lazily via + Cloudflare custom hostnames, exposing DNS instructions until verified. + + @image-hosting-config/read-any-member @api + Scenario: Any member can read the config + Given an org member + When they read the image-hosting config + Then it is returned + + @image-hosting-config/write-requires-admin @api + Scenario: Only admins can change the config + Given a non-admin role + When they PUT or DELETE the config + Then the API responds 403 + + @image-hosting-config/default-disabled @api + Scenario: No config reports disabled + Given no config row + When the config is read + Then it reports enabled:false + + @image-hosting-config/no-domain @api + Scenario: Without a custom domain there are no DNS instructions + Given a config with no custom domain + When it is read + Then domainStatus is none and dnsInstructions is null + + @image-hosting-config/domain-verified @api + Scenario: A verified domain reports verified + Given a domain whose verification timestamp is set + When the config is read + Then domainStatus is verified + + @image-hosting-config/referer-allowlist @api + Scenario: The referer allowlist is parsed + Given a stored referer allowlist + When the config is read + Then the allowlist is returned as an array + + @image-hosting-config/no-recheck-verified @api + Scenario: A verified domain is not re-checked + Given an already-verified domain + When the config is read + Then Cloudflare is not called + + @image-hosting-config/lazy-verify @api + Scenario: A pending domain verifies lazily when active + Given a pending domain that Cloudflare now reports active + When the config is read + Then the domain is marked verified + + @image-hosting-config/stays-pending @api + Scenario: A domain stays pending while Cloudflare is non-active + Given a pending domain that Cloudflare reports non-active + When the config is read + Then it stays pending + + @image-hosting-config/dns-cname @api + Scenario: DNS instructions use CNAME when Cloudflare is configured + Given Cloudflare custom hostnames configured + When the config with a domain is read + Then dnsInstructions use recordType CNAME + + @image-hosting-config/dns-manual @api + Scenario: DNS instructions are manual without Cloudflare + Given Cloudflare is not configured + When the config with a domain is read + Then dnsInstructions use recordType manual + + @image-hosting-config/create @api + Scenario: Enabling creates a config row + Given an admin + When they enable image hosting with no domain + Then a config row is created + + @image-hosting-config/cf-register @api + Scenario: A custom domain registers with Cloudflare + Given Cloudflare configured + When an admin sets a custom domain + Then Cloudflare register is called and the hostname id stored + + @image-hosting-config/domain-change @api + Scenario: Changing the domain re-registers + Given an existing custom domain + When the admin changes it + Then Cloudflare delete then register is called + + @image-hosting-config/cf-conflict @api + Scenario: A Cloudflare registration conflict surfaces + Given Cloudflare returns a 409 conflict + When an admin sets a custom domain + Then the API responds 409 + + @image-hosting-config/disable-via-delete @api + Scenario: Disabling must use DELETE + Given an admin + When they PUT enabled=false + Then the API responds 400 + + @image-hosting-config/reject-app-host @api + Scenario: The custom domain cannot be the app host + Given an admin + When they set a custom domain equal to the app host + Then the API responds 400 diff --git a/spec/image-hosting.feature b/spec/image-hosting.feature new file mode 100644 index 00000000..3304bdb2 --- /dev/null +++ b/spec/image-hosting.feature @@ -0,0 +1,124 @@ +Feature: Image hosting + Users upload images to a public-image-hosting bucket — either directly (uPic-style + base64/multipart through the API) or via a presigned URL — and serve them under an + optional custom domain. Paths are validated and collisions auto-suffixed. + + @image-hosting/json-missing-file @api + Scenario: A JSON upload without a file is rejected + Given a JSON request with no base64 file field + When it is posted + Then the API responds 400 + + @image-hosting/json-auth @api + Scenario: A JSON upload requires authentication + Given a JSON upload with no auth + When it is posted + Then the API responds 401 + + @image-hosting/unsupported-content-type @api + Scenario: Unsupported content types are rejected + Given a text/plain request + When it is posted + Then the API responds 415 + + @image-hosting/upic-upload @api + Scenario: A base64 PNG uploads (uPic) + Given an authenticated user + When they post a base64 PNG via JSON + Then it is accepted and stored + + @image-hosting/json-explicit-path @api + Scenario: A JSON upload honors an explicit path + Given a base64 upload with an explicit path + When it is posted + Then the image is stored at that path + + @image-hosting/invalid-base64 @api + Scenario: Invalid base64 is rejected + Given a JSON upload with malformed base64 + When it is posted + Then the API responds 400 + + @image-hosting/presign-session-only @api + Scenario: Presign requires a session, not an API key + Given an API-key request to presign + When it is called + Then the API responds 401 + + @image-hosting/requires-config @api + Scenario: Image hosting requires a configured org + Given an org with no image-hosting config + When a user presigns an upload + Then the API responds 403 + + @image-hosting/requires-storage @api + Scenario: Image hosting requires a storage + Given no storage configured + When a user presigns an upload + Then the API responds 503 + + @image-hosting/presign @api + Scenario: Presign returns a draft row and upload URL + Given an authenticated user + When they presign an upload + Then a draft row and presigned upload URL are returned + + @image-hosting/path-traversal @api + Scenario: Path traversal is rejected + Given a path containing ".." + When an upload is presigned + Then the API responds 400 + + @image-hosting/path-depth @api + Scenario: Excessive path depth is rejected + Given a path deeper than the limit + When an upload is presigned + Then the API responds 400 + + @image-hosting/disallowed-svg @api + Scenario: Disallowed mime types are rejected + Given an SVG upload + When it is presigned + Then the API responds 400 + + @image-hosting/size-limit @api + Scenario: Oversized uploads are rejected + Given a file larger than 20 MB + When it is presigned + Then the API responds 413 + + @image-hosting/collision-suffix @api + Scenario: Path collisions are auto-suffixed + Given a path that already exists + When an upload is presigned + Then the path is auto-suffixed + + @image-hosting/default-path @api + Scenario: A default path is derived from the filename + Given an upload with no explicit path + When it is presigned + Then a default path is derived from the blob filename + + @image-hosting/multipart-upload @api + Scenario: A multipart upload stores the image + Given an authenticated user + When they upload via multipart + Then the image is stored and a tool response returned + + @image-hosting/active-after-upload @api + Scenario: The row becomes active after upload + Given a multipart upload + When it completes + Then the row status is active + + @image-hosting/content-length-guard @api + Scenario: Oversized bodies are rejected before parsing + Given a Content-Length over the limit + When the request arrives + Then the API responds 413 before parsing the body + + @image-hosting/custom-domain @api + Scenario: A verified custom domain is used in the URL + Given a configured and verified custom domain + When an image URL is built + Then it uses the custom domain diff --git a/spec/invite-codes.feature b/spec/invite-codes.feature new file mode 100644 index 00000000..440a1cde --- /dev/null +++ b/spec/invite-codes.feature @@ -0,0 +1,56 @@ +Feature: Invite codes + Admins mint single-use invite codes; signup validates them. + + @invite-codes/admin-auth @api + Scenario: Listing invite codes requires authentication + Given an unauthenticated request + When it lists invite codes + Then the API responds 401 + + @invite-codes/admin-only @api + Scenario: Only admins manage invite codes + Given an authenticated non-admin user + When they list invite codes + Then the API responds 403 + + @invite-codes/list @api + Scenario: Admins list created codes with totals + Given created invite codes + When an admin lists them + Then the codes and total are returned + + @invite-codes/generate @api + Scenario: Admins generate a batch of codes + Given an authenticated admin + When they generate N codes + Then N codes are created and returned + + @invite-codes/generate-expiry @api + Scenario: Generated codes can carry an expiry + Given an authenticated admin + When they generate codes with expiresInDays + Then the codes expire at the requested time + + @invite-codes/generate-limit @api + Scenario: Batch size is capped + Given an authenticated admin + When they request more than the maximum batch size + Then the API rejects the request + + @invite-codes/delete @api + Scenario: Admins delete an unused code + Given an unused invite code + When an admin deletes it + Then it is removed + + @invite-codes/delete-used @api + Scenario: A used code cannot be deleted + Given an already-used invite code + When an admin deletes it + Then the API rejects the request + + @invite-codes/validate @api + Scenario: Signup validates an invite code + Given a valid unused invite code + When it is validated + Then it reports as valid diff --git a/spec/licensing-admin.feature b/spec/licensing-admin.feature new file mode 100644 index 00000000..c7552cb4 --- /dev/null +++ b/spec/licensing-admin.feature @@ -0,0 +1,88 @@ +Feature: License administration + Admins pair the instance with Cloud (device-style code + poll), which stores a + signed binding certificate; they can refresh the binding and unbind. All + endpoints are admin-only and validate the certificate's signing key. + + @licensing-admin/auth-required @api + Scenario: License admin endpoints require authentication + Given an unauthenticated request + When it calls a licensing admin endpoint + Then the API responds 401 + + @licensing-admin/admin-only @api + Scenario: License admin endpoints require an admin + Given a non-admin user + When they initiate pairing + Then the API responds 403 + + @licensing-admin/pair-initiate @api + Scenario: Pairing initiates against Cloud + Given an admin + When they start pairing + Then Cloud is called and pairing info is returned + + @licensing-admin/poll-pending @api + Scenario: Polling reports pending + Given a pairing not yet approved + When the admin polls + Then a pending status is returned + + @licensing-admin/poll-approved @api + Scenario: Polling stores the binding on approval + Given a pairing approved by Cloud + When the admin polls + Then the binding is stored and approved is returned + + @licensing-admin/store-cert @api + Scenario: The pairing certificate is stored on approval + Given an approved pairing + When the admin polls + Then the certificate is persisted + + @licensing-admin/reject-invalid-cert @api + Scenario: An invalid certificate is rejected + Given an approved response with an invalid certificate + When the admin polls + Then it is rejected + + @licensing-admin/reject-missing-cert @api + Scenario: A missing certificate is rejected + Given an approved response with no certificate + When the admin polls + Then it is rejected + + @licensing-admin/untrusted-key-rollback @api + Scenario: An untrusted signing key rolls back the binding + Given an approved certificate signed by an untrusted key + When the admin polls + Then it reports untrusted and rolls back the orphaned Cloud binding + + @licensing-admin/refresh @api + Scenario: Refresh succeeds for a bound instance + Given an existing binding and Cloud responding OK + When the admin refreshes + Then it succeeds + + @licensing-admin/refresh-unbound @api + Scenario: Refresh on an unbound instance is a no-op success + Given no binding + When the admin refreshes + Then it returns success with a null last refresh time + + @licensing-admin/unbind @api + Scenario: Unbinding deletes the binding + Given an existing binding + When the admin unbinds + Then Cloud is told, the binding row is deleted, and deleted:true is returned + + @licensing-admin/unbind-cloud-fail @api + Scenario: Unbinding clears local state even if Cloud fails + Given Cloud unbind fails + When the admin unbinds + Then the local binding is still cleared + + @licensing-admin/unbind-idempotent @api + Scenario: Unbinding with no binding still succeeds + Given no binding + When the admin unbinds + Then deleted:true is returned diff --git a/spec/licensing.feature b/spec/licensing.feature new file mode 100644 index 00000000..a4fa56e9 --- /dev/null +++ b/spec/licensing.feature @@ -0,0 +1,70 @@ +Feature: Licensing + An instance can be bound to a license that unlocks paid features. Binding state + is read publicly; a cron endpoint periodically refreshes the cached certificate + and syncs pending traffic reports to the cloud. + + @licensing/state-unbound @api + Scenario: An unbound instance reports no binding + Given no license binding row exists + When the licensing state is read + Then it reports bound:false + + @licensing/state-bound @api + Scenario: A bound instance reports its plan and features + Given a license binding with a cached certificate + When the licensing state is read + Then it reports bound:true with the plan and features + + @licensing/state-bound-no-cert @api + Scenario: A bound instance with no cached cert reports binding only + Given a license binding whose cached certificate is null + When the licensing state is read + Then it reports bound:true with no plan or features + + @licensing/public @api + Scenario: Licensing state is public + Given any instance + When the licensing state is read without authentication + Then it is still returned + + @licensing/refresh-auth @api + Scenario: The refresh cron endpoint requires its secret + Given the refresh cron endpoint + When it is called without the matching cron secret + Then the API responds 401 + + @licensing/refresh-noop @api + Scenario: Refreshing an unbound instance is a no-op success + Given no license binding and the correct cron secret + When the refresh cron endpoint is called + Then it responds 200 ok without refreshing + + @licensing/refresh-runs @api + Scenario: Refresh runs for a stale binding + Given a binding whose last refresh is old and the correct cron secret + When the refresh cron endpoint is called + Then it refreshes the certificate and responds 200 ok + + @licensing/refresh-error-swallowed @api + Scenario: A refresh failure never fails the cron + Given a binding whose refresh throws + When the refresh cron endpoint is called + Then it still responds 200 ok + + @licensing/traffic-cron-public @api + Scenario: The traffic-sync cron endpoint is reachable + Given the dedicated traffic cron endpoint + When it is called + Then it is served without a user session + + @licensing/traffic-sync @api + Scenario: The traffic cron syncs pending reports + Given pending traffic reports and the correct cron secret + When the traffic cron endpoint is called + Then the pending reports are synced to the cloud + + @licensing/traffic-cron-secret @api + Scenario: The traffic cron endpoint requires its secret + Given the dedicated traffic cron endpoint + When it is called without the cron secret + Then the API responds 401 diff --git a/spec/notifications.feature b/spec/notifications.feature new file mode 100644 index 00000000..857ad5cd --- /dev/null +++ b/spec/notifications.feature @@ -0,0 +1,50 @@ +Feature: Notifications + Per-user in-app notifications with unread tracking. + + @notifications/auth @api + Scenario: Notifications require authentication + Given an unauthenticated request + When it lists notifications + Then the API responds 401 + + @notifications/list @api + Scenario: A user lists their notifications with pagination + Given a user with notifications + When they list notifications + Then the page and totals are returned + + @notifications/unread-filter @api + Scenario: A user filters to unread notifications + Given a user with read and unread notifications + When they list with the unread filter + Then only unread notifications are returned + + @notifications/isolation @api + Scenario: Users never see another user's notifications + Given two users with their own notifications + When one lists notifications + Then only their own are returned + + @notifications/stats @api + Scenario: The unread count is reported + Given a user with unread notifications + When they request notification stats + Then the unread count is correct + + @notifications/mark-read @api + Scenario: Marking a notification read returns 204 + Given a user with an unread notification + When they mark it read + Then the API responds 204 + + @notifications/mark-read-foreign @api + Scenario: A user cannot mark another user's notification read + Given a notification owned by another user + When the user marks it read + Then the API responds 404 + + @notifications/mark-all @api + Scenario: Marking all read returns the count + Given a user with several unread notifications + When they mark all read + Then the count of updated notifications is returned diff --git a/spec/objects.feature b/spec/objects.feature new file mode 100644 index 00000000..1f81ed9b --- /dev/null +++ b/spec/objects.feature @@ -0,0 +1,239 @@ +Feature: Objects + Files and folders ("matters") are the core entity. Clients create folders, presign + file uploads directly to S3 then confirm, browse/rename/move, trash and restore, + permanently purge, copy, and transfer across spaces — with name-conflict resolution + and quota enforcement throughout. + + @objects/auth-required @api + Scenario: Object access requires authentication + Given an unauthenticated request + When it calls the objects API + Then the API responds 401 + + @objects/list-empty @api + Scenario: A new drive lists nothing + Given a user with no objects + When they list objects + Then an empty list is returned + + @objects/list-pagination @api + Scenario: Object listing paginates + Given many objects + When they are listed with pagination params + Then the requested page is returned + + @objects/list-by-parent @api + Scenario: Objects filter by parent folder + Given nested folders + When listing filters by parent + Then only that parent's children are returned + + @objects/list-by-status @api + Scenario: Objects filter by status + Given active and trashed objects + When listing filters by status + Then only matching objects are returned + + @objects/create-folder @api + Scenario: A folder is created + Given an authenticated user + When they create a folder + Then the folder is created + + @objects/create-invalid @api + Scenario: Invalid create input is rejected + Given invalid object input + When it is posted + Then the API responds 400 + + @objects/create-no-storage @api + Scenario: Creating a file needs a storage + Given no storage is available + When a file object is created + Then the API responds 500 + + @objects/create-file-presign @api + Scenario: Creating a file returns a presigned upload URL + Given a configured storage + When a file object is created + Then a draft with a presigned upload URL is returned + + @objects/detail @api + Scenario: An object's detail is returned + Given an existing object + When its detail is requested + Then the detail is returned + + @objects/detail-missing @api + Scenario: A missing object detail is 404 + Given a missing object id + When its detail is requested + Then the API responds 404 + + @objects/rename @api + Scenario: An object is renamed + Given an existing object + When it is renamed + Then the new name is persisted + + @objects/move @api + Scenario: An object is moved + Given an existing object + When it is moved to a new parent + Then its location is updated + + @objects/confirm-upload @api + Scenario: A draft upload is confirmed + Given a draft object whose bytes were uploaded + When the upload is confirmed + Then the object becomes active + + @objects/confirm-non-draft @api + Scenario: Confirming a non-draft object is 404 + Given a non-draft object + When confirm is requested + Then the API responds 404 + + @objects/cancel-draft @api + Scenario: A draft upload is cancelled + Given a draft object + When it is cancelled + Then it is deleted and its S3 object cleaned up + + @objects/delete-requires-trash @api + Scenario: Active objects cannot be deleted directly + Given an active object + When a permanent delete is attempted + Then it is rejected — it must be trashed first + + @objects/trash @api + Scenario: A file is trashed + Given an active file + When it is trashed + Then its status becomes trashed + + @objects/trash-cascade @api + Scenario: Trashing a folder cascades to children + Given a folder with children + When it is trashed + Then its children are trashed too + + @objects/restore @api + Scenario: A trashed file is restored + Given a trashed file + When it is restored + Then it becomes active again + + @objects/list-trashed @api + Scenario: Trashed roots list under their active parents + Given trashed objects under active parents + When trashed objects are listed + Then trashed folder roots are returned nested under active parents + + @objects/purge-folder @api + Scenario: A trashed folder is permanently deleted + Given a trashed folder + When it is permanently deleted + Then it is removed + + @objects/purge-file-s3 @api + Scenario: Purging a trashed file cleans up S3 + Given a trashed file + When it is permanently deleted + Then it is removed and its S3 object deleted + + @objects/purge-all @api + Scenario: Emptying the trash purges everything + Given trashed items + When the trash is emptied + Then all trashed items are purged + + @objects/download-url @api + Scenario: A file detail returns a download URL + Given a file + When its detail is requested + Then a download URL is returned + + @objects/download-traffic @api + Scenario: Downloads report Cloud traffic for bound instances + Given a bound instance + When a file download URL is requested + Then Cloud traffic is reported before returning the URL + + @objects/copy-file @api + Scenario: A file is copied + Given a file + When it is copied + Then a new file is created from the source via S3 + + @objects/copy-folder @api + Scenario: A folder is copied + Given a folder + When it is copied + Then the folder tree is duplicated + + @objects/create-conflict @api + Scenario: A duplicate folder name conflicts + Given an existing folder name + When a folder with the same name is created + Then the API responds 409 NAME_CONFLICT + + @objects/create-conflict-rename @api + Scenario: onConflict=rename auto-renames on create + Given an existing folder name + When a folder is created with onConflict=rename + Then it succeeds with an auto-renamed folder + + @objects/rename-conflict @api + Scenario: Renaming into a taken name conflicts + Given a sibling with the target name + When an object is renamed to it + Then the API responds 409 NAME_CONFLICT + + @objects/move-conflict @api + Scenario: Moving into a collision conflicts + Given a destination with a colliding name + When an object is moved without onConflict + Then the API responds 409 + + @objects/restore-conflict @api + Scenario: Restoring into a taken name conflicts + Given a trashed object whose name is now taken + When it is restored + Then the API responds 409 + + @objects/transfer-copy @api + Scenario: A file copies into an editable team space + Given a team space the user can edit + When a file is copied into it + Then the copy is created there + + @objects/transfer-move @api + Scenario: A file moves into a team space + Given a team space the user can edit + When a file is moved into it + Then the source is deleted and its quota released + + @objects/transfer-folder @api + Scenario: A folder transfers recursively + Given a folder + When it is transferred into a target space + Then it is copied recursively + + @objects/transfer-permission @api + Scenario: Transfer requires target membership + Given a team the user is not a member of + When a transfer is attempted + Then it is rejected + + @objects/transfer-quota @api + Scenario: Transfer respects the target quota + Given a target space whose quota is exceeded + When a transfer is attempted + Then it is rejected + + @objects/transfer-same-space @api + Scenario: Transfer to the same space is rejected + Given a source and target that are the same space + When a transfer is attempted + Then it is rejected diff --git a/spec/profile.feature b/spec/profile.feature new file mode 100644 index 00000000..4e11eea2 --- /dev/null +++ b/spec/profile.feature @@ -0,0 +1,45 @@ +Feature: Public profiles + Each user has a public profile page listing their public shares, reachable + without authentication. Profile paths render as navigable breadcrumbs. + + @profile/user-not-found @api + Scenario: An unknown user id has no profile + Given a user id that does not exist + When the profile is requested + Then the API responds 404 + + @profile/user-info @api + Scenario: A profile returns user info and shares + Given an existing user + When their profile is requested + Then their user info and shares are returned + + @profile/public @api + Scenario: Profiles are public + Given an existing user + When their profile is requested without authentication + Then it is still returned + + @profile/no-personal-org @api + Scenario: A user without a personal org still has a profile + Given an existing user with no personal org + When their profile is requested + Then their user info is returned + + @profile/unknown-username @api + Scenario: An unknown username has no public listing + Given a username that does not exist + When its public listing is requested + Then the API responds 404 + + @profile/empty-listing @api + Scenario: A known user with no public files lists nothing + Given a known user with no public files + When their public listing is requested + Then an empty item list and breadcrumb are returned + + @profile/breadcrumb-segments @domain + Scenario: A profile path splits into breadcrumb segments + Given a nested profile path + When it is split into breadcrumb segments + Then each path level becomes one ordered segment diff --git a/spec/quota-store.feature b/spec/quota-store.feature new file mode 100644 index 00000000..ef22b286 --- /dev/null +++ b/spec/quota-store.feature @@ -0,0 +1,166 @@ +Feature: Quota Store + Pro instances bound to Cloud sell storage/traffic packages and subscriptions. + Checkout and credit calls proxy to Cloud; a signed quota-change webhook delivers + entitlements back, adjusting org quota idempotently with an audit trail. + + @quota-store/requires-binding @api + Scenario: Store endpoints are hidden until Cloud is bound + Given an instance not bound to Cloud + When the self-service store endpoints are called + Then they are hidden + + @quota-store/feature-gated @api + Scenario: The quota webhook requires the quota_store feature + Given an instance without Pro quota_store + When a quota-change webhook arrives + Then the API responds 402 + + @quota-store/checkout-return-origin @api + Scenario: Checkout return URLs use the detected site origin + Given a configured site origin + When a checkout is created + Then the return URL uses the site origin + + @quota-store/checkout-origin-antispoof @api + Scenario: Spoofed forwarded origins are ignored for return URLs + Given a spoofed or non-https forwarded origin + When a checkout is created + Then the forwarded origin is ignored + + @quota-store/checkout-access @api + Scenario: Checkout is restricted to accessible orgs + Given a target org the user cannot access + When they start checkout + Then it is rejected + + @quota-store/team-checkout-owner-only @api + Scenario: Team checkout is owner-only + Given a non-owner team member + When they start team checkout + Then it is rejected + + @quota-store/team-checkout @api + Scenario: A team owner checks out for the team + Given a team owner + When they start team checkout + Then it targets the team org + + @quota-store/no-double-plan @api + Scenario: A workspace cannot hold two active plans + Given a workspace with an active plan + When a recurring checkout is started + Then it is rejected + + @quota-store/fixed-checkout @api + Scenario: Fixed-duration packages check out without credit discounts + Given a fixed-duration package + When checkout is created + Then no credit discount fields are included + + @quota-store/subscription-portal @api + Scenario: A subscription portal opens for the active plan + Given an active workspace plan + When the portal is requested + Then a subscription portal is created + + @quota-store/list-packages @api + Scenario: Purchasable packages and orders are listed + Given a bound instance + When the store is queried + Then packages, targets, checkout, and orders are returned + + @quota-store/checkout-currency-guard @api + Scenario: Client-supplied currency is rejected + Given a checkout request carrying currency fields + When it is submitted + Then it is rejected before proxying to Cloud + + @quota-store/credit-balance @api + Scenario: Credit balance and gift-card redemption proxy to Cloud + Given a bound instance + When credit balance or gift-card redemption is requested + Then it is proxied through the credit endpoints + + @quota-store/credit-ledger @api + Scenario: The credit ledger proxies to Cloud + Given a bound instance + When credit ledger entries are requested + Then they are proxied through the credit endpoints + + @quota-store/order-continue-cancel @api + Scenario: Orders can continue payment or cancel + Given an order + When payment is continued or the order cancelled + Then it is proxied through Cloud + + @quota-store/order-org-scope @api + Scenario: Order actions are org-scoped + Given an order belonging to another org + When payment continuation or cancellation is attempted + Then it is rejected + + @quota-store/checkout-error-surfacing @api + Scenario: Cloud checkout errors are surfaced + Given Cloud returns a checkout error + When checkout is attempted + Then the error is surfaced + + @quota-store/webhook-auth-required @api + Scenario: The quota webhook requires authentication + Given a quota-change webhook with no auth + When it arrives + Then it is rejected + + @quota-store/webhook-token-expiry @api + Scenario: Expired webhook tokens are rejected + Given an expired webhook event token + When the webhook arrives + Then it is rejected + + @quota-store/webhook-token-audience @api + Scenario: Webhook tokens must target this instance + Given a webhook token with the wrong audience + When it arrives + Then it is rejected + + @quota-store/webhook-rejects-commerce @api + Scenario: Commerce-only events are rejected on the quota webhook + Given a credit-only commerce fulfillment event + When it hits the quota webhook + Then it is rejected + + @quota-store/webhook-records-entitlement @api + Scenario: A valid webhook records an entitlement once with audit + Given a valid quota-change webhook + When it is delivered + Then the active entitlement is recorded once and audited + + @quota-store/webhook-subscription-delivery @api + Scenario: Subscriptions deliver storage and traffic entitlements + Given an initial subscription event + When it is delivered + Then storage and traffic entitlements are delivered under a stable source id + + @quota-store/webhook-renewal @api + Scenario: Subscription renewals replace bytes and extend expiry + Given a renewal event + When it is delivered + Then plan bytes are replaced and expiry extended + + @quota-store/webhook-accumulate @api + Scenario: Repeated increases for one order accumulate + Given repeated increase events for the same order and resource + When they are delivered + Then the entitlement bytes accumulate + + @quota-store/webhook-decrease @api + Scenario: Decreases reduce accumulated bytes without revoking the remainder + Given accumulated order entitlement bytes + When a decrease is delivered + Then the bytes decrease without revoking the remainder + + @quota-store/webhook-idempotent @api + Scenario: Replayed decrease events are idempotent + Given a decrease event already processed + When the same event is replayed + Then it does not double-deduct diff --git a/spec/quotas.feature b/spec/quotas.feature new file mode 100644 index 00000000..1b0012cd --- /dev/null +++ b/spec/quotas.feature @@ -0,0 +1,70 @@ +Feature: Quotas + Every org has a storage + monthly-traffic quota. Admins inspect all org quotas; + each user reads their own effective quota (base allowance plus active license + entitlements). + + @quotas/admin-auth-required @api + Scenario: The admin quota listing requires authentication + Given an unauthenticated request + When it calls the admin quotas API + Then the API responds 401 + + @quotas/admin-only @api + Scenario: Non-admins cannot list org quotas + Given an authenticated non-admin user + When they call the admin quotas API + Then the API responds 403 + + @quotas/default-row @api + Scenario: A default quota row exists from signup + Given a freshly signed-up org + When an admin lists quotas + Then the default quota row created at signup is returned + + @quotas/normalizes-stale-period @api + Scenario: A stale monthly traffic period is normalized in the response only + Given a quota whose monthly traffic period has rolled over + When an admin reads it + Then the response shows the current period without writing to the database + + @quotas/list-with-org @api + Scenario: Quotas are listed with their org info + Given configured org quotas + When an admin lists quotas + Then each quota is returned with its org metadata + + @quotas/effective-with-entitlements @api + Scenario: Effective quota includes active entitlements + Given an org with active license entitlements + When an admin lists effective quotas + Then the listed quota reflects the base allowance plus active entitlements + + @quotas/plan-labels @api + Scenario: Effective quota exposes plan and extra-quota labels + Given an org with an active plan and extra quota + When an admin lists effective quotas + Then the active plan and extra-quota labels are exposed + + @quotas/me-auth-required @api + Scenario: Reading my own quota requires authentication + Given an unauthenticated request + When it calls the personal quota API + Then the API responds 401 + + @quotas/me-default @api + Scenario: My quota falls back to the built-in default + Given no default-quota system option is set + When an authenticated user reads their quota + Then the built-in 10MB default is returned + + @quotas/me-no-org @api + Scenario: A user with no org has no quota + Given an authenticated user with no org + When they read their quota + Then the API responds 404 + + @quotas/me-effective @api + Scenario: My quota includes my active entitlements + Given an authenticated user whose org has active entitlements + When they read their quota + Then the base quota plus active entitlements and labels are returned diff --git a/spec/redirect.feature b/spec/redirect.feature new file mode 100644 index 00000000..74f90811 --- /dev/null +++ b/spec/redirect.feature @@ -0,0 +1,130 @@ +Feature: Public redirects + Short links resolve public assets: a direct-share token (ds_) streams a shared + file, and an image-hosting token (ih_) serves a hosted image. Both meter traffic + quota (refunding on failure) and enforce the image referer allowlist. + + @redirect/direct-share @api + Scenario: A valid direct-share link redirects to the file + Given a valid ds_ token + When the link is followed + Then it 302-redirects with attachment disposition and no-store cache + + @redirect/unknown-ds-token @api + Scenario: An unknown direct-share token is not found + Given an unknown ds_ token + When the link is followed + Then the API responds 404 + + @redirect/landing-token-rejected @api + Scenario: A landing-share token is not a direct link + Given a landing share token at the direct-link path + When it is followed + Then the API responds 404 + + @redirect/ds-quota-exhausted @api + Scenario: A direct share over traffic quota is refused + Given an exhausted direct-share traffic quota + When the link is followed + Then the API responds 422 + + @redirect/ds-consumes-quota @api + Scenario: A successful direct share consumes traffic quota + Given a valid ds_ token within quota + When the link is followed + Then traffic quota is consumed + + @redirect/ds-refund-on-failure @api + Scenario: A failed direct-share signing refunds traffic + Given direct-share signing fails + When the link is followed + Then traffic and download count are refunded + + @redirect/image @api + Scenario: A valid image link serves the image + Given a valid ih_ token for an active image + When the link is followed + Then it 302-redirects with inline disposition and no-store cache + + @redirect/image-strip-ext @api + Scenario: Image links resolve regardless of extension + Given an image link with a file extension + When the link is followed + Then the extension is stripped and the same image resolves + + @redirect/unknown-ih-token @api + Scenario: An unknown image token is not found + Given an unknown ih_ token + When the link is followed + Then the API responds 404 + + @redirect/image-draft-hidden @api + Scenario: Draft images are not served + Given an image with draft status + When its link is followed + Then the API responds 404 + + @redirect/image-access-count @api + Scenario: A served image increments its access count + Given a valid image link + When it is followed successfully + Then the access count increments by one + + @redirect/image-consumes-quota @api + Scenario: A served image consumes traffic quota + Given a valid image link within quota + When it is followed + Then traffic quota is consumed + + @redirect/image-refund-on-failure @api + Scenario: A failed image signing refunds traffic + Given image signing fails + When the link is followed + Then traffic is refunded + + @redirect/image-quota-boundary @api + Scenario: Image redirects stop at the quota boundary + Given the first redirect consumes the remaining monthly traffic + When the next image redirect is attempted + Then it is refused + + @redirect/no-count-on-404 @api + Scenario: A 404 does not increment access count + Given a missing image + When its link is followed + Then the access count is unchanged + + @redirect/referer-empty-allowlist @api + Scenario: An empty allowlist permits any referer + Given an empty referer allowlist + When an image link is followed from any referer + Then it is allowed + + @redirect/referer-match @api + Scenario: A matching referer is allowed + Given a referer matching the allowlist + When the image link is followed + Then it is allowed + + @redirect/referer-missing-ok @api + Scenario: A missing referer is allowed + Given no referer (direct access) + When the image link is followed + Then it is allowed + + @redirect/referer-mismatch @api + Scenario: A foreign referer is blocked + Given a referer from a different origin + When the image link is followed + Then the API responds 403 + + @redirect/referer-subdomain @api + Scenario: Referer matching requires an exact origin + Given a referer from a subdomain of an allowed origin + When the image link is followed + Then the API responds 403 + + @redirect/no-count-on-403 @api + Scenario: A blocked referer does not increment access count + Given a blocked referer + When the image link is followed + Then the access count is unchanged diff --git a/spec/shares.feature b/spec/shares.feature new file mode 100644 index 00000000..743eaa29 --- /dev/null +++ b/spec/shares.feature @@ -0,0 +1,196 @@ +Feature: Shares + Users share files/folders as landing shares (a public viewer page) or direct + shares (a one-hop file link). Shares carry optional password, recipients, expiry, + and download limits; recipients can save a share into their own drive. + + @shares/auth-required @api + Scenario: Creating a share requires authentication + Given an unauthenticated request + When it creates a share + Then the API responds 401 + + @shares/create-landing @api + Scenario: A landing share is created + Given an authenticated user + When they create a landing share without a password + Then a 201 with the correct shape is returned + + @shares/create-password @api + Scenario: A landing share can be password-protected + Given an authenticated user + When they create a landing share with a password + Then the password hash is stored + + @shares/create-recipients @api + Scenario: A landing share can name recipients + Given an authenticated user + When they create a landing share with recipients + Then share-recipient rows are inserted + + @shares/create-direct @api + Scenario: A direct share returns a direct URL + Given a file + When a direct share is created + Then a direct URL is returned + + @shares/direct-no-folder @api + Scenario: Direct shares cannot target a folder + Given a folder + When a direct share is created + Then the API responds 400 DIRECT_NO_FOLDER + + @shares/direct-no-password @api + Scenario: Direct shares cannot carry a password + Given a direct share request with a password + When it is created + Then the API responds 400 DIRECT_NO_PASSWORD + + @shares/direct-no-recipients @api + Scenario: Direct shares cannot name recipients + Given a direct share request with recipients + When it is created + Then the API responds 400 DIRECT_NO_RECIPIENTS + + @shares/create-cross-org @api + Scenario: A share's matter must belong to the org + Given a matterId from another org + When a share is created + Then the API responds 404 + + @shares/create-expiry @api + Scenario: A share can carry an expiry + Given an expiresAt in the request + When the share is created + Then the expiry is stored + + @shares/create-download-limit @api + Scenario: A share can carry a download limit + Given a downloadLimit in the request + When the share is created + Then the limit is stored + + @shares/create-notify-best-effort @api + Scenario: Share creation succeeds even if notification fails + Given share-created notification dispatch rejects + When a share is created + Then a 201 is still returned + + @shares/list-empty @api + Scenario: A new user has no shares + Given a user with no shares + When they list shares + Then an empty list is returned + + @shares/list-pagination @api + Scenario: Shares list with pagination fields + Given several shares + When they are listed + Then pagination fields are returned + + @shares/list-isolation @api + Scenario: Users only see their own shares + Given shares owned by another user + When a user lists shares + Then the other user's shares are not returned + + @shares/list-filter-status @api + Scenario: Shares filter by status + Given shares of various statuses + When listed with status=active + Then only active shares are returned + + @shares/detail-creator @api + Scenario: The creator sees full share detail + Given a share viewed by its creator + When the detail is requested + Then recipients and creator-only fields are included + + @shares/detail-non-creator @api + Scenario: Non-creators see a reduced landing view + Given a landing share viewed by a non-creator + When the detail is requested + Then recipients and internal ids are hidden + + @shares/detail-not-found @api + Scenario: An unknown share token is not found + Given a non-existent token + When the detail is requested + Then the API responds 404 + + @shares/no-self-view-count @api + Scenario: The creator does not inflate view counts + Given the creator viewing their own share + When the detail is requested + Then the view count does not increment + + @shares/save-direct-forbidden @api + Scenario: Direct shares cannot be saved to drive + Given a direct share + When a save-to-drive is attempted + Then the API responds 400 DIRECT_SAVE_FORBIDDEN + + @shares/save-trashed-gone @api + Scenario: A trashed shared matter is gone + Given a share whose matter was trashed + When a save is attempted + Then the API responds 410 + + @shares/save-to-drive @api + Scenario: A landing share is saved to the user's drive + Given a landing share + When a recipient saves it to their personal drive + Then a 201 is returned + + @shares/save-quota-exceeded @api + Scenario: Saving over quota is refused + Given an exhausted target-org quota + When a share is saved + Then the API responds 400 QUOTA_EXCEEDED + + @shares/save-target-permission @api + Scenario: Saving to a team org requires membership + Given a non-personal target org where the user has no member role + When a share is saved there + Then the API responds 403 + + @shares/save-recipient-bypass @api + Scenario: A listed recipient can save a password share + Given a password-protected share and a listed recipient + When they save it + Then it is allowed + + @shares/save-cookie-bypass @api + Scenario: A valid share-token cookie unlocks a password share + Given a non-recipient with a valid sharetk cookie + When they save a password-protected share + Then the password wall is bypassed + + @shares/save-viewer-forbidden @api + Scenario: A viewer role cannot save into the org + Given a user with a viewer role in the target org + When they save a share there + Then the API responds 403 + + @shares/delete @api + Scenario: A creator revokes their share + Given a creator's share + When they delete it + Then its status becomes revoked + + @shares/delete-non-creator @api + Scenario: Non-creators cannot delete a share + Given a share owned by someone else + When a non-creator deletes it + Then the API responds 403 + + @shares/received-list @api + Scenario: Users see shares addressed to them + Given shares addressed by id and by email + When the received list is requested + Then matching shares are returned and unrelated ones hidden + + @shares/received-excludes-revoked @api + Scenario: Revoked shares drop off the received list + Given a revoked share addressed to the user + When the received list is requested + Then it is excluded diff --git a/spec/site-invitations.feature b/spec/site-invitations.feature new file mode 100644 index 00000000..2582c1f3 --- /dev/null +++ b/spec/site-invitations.feature @@ -0,0 +1,50 @@ +Feature: Site invitations + Admins invite people to register; the invite is consumed at signup. + + @site-invitations/admin-auth @api + Scenario: Managing invitations requires authentication + Given an unauthenticated request + When it lists site invitations + Then the API responds 401 + + @site-invitations/admin-only @api + Scenario: Only admins manage site invitations + Given an authenticated non-admin user + When they create a site invitation + Then the API responds 403 + + @site-invitations/create @api + Scenario: Admins create a site invitation + Given an authenticated admin + When they invite an email address + Then an invitation is created and returned + + @site-invitations/list @api + Scenario: Admins list invitations with totals + Given existing site invitations + When an admin lists them + Then the invitations and total are returned + + @site-invitations/resend @api + Scenario: Resending an invitation rotates its token + Given a pending invitation + When an admin resends it + Then a new token is issued + + @site-invitations/revoke @api + Scenario: Admins revoke an invitation + Given a pending invitation + When an admin revokes it + Then it is marked revoked + + @site-invitations/duplicate @api + Scenario: A duplicate pending invitation is rejected + Given a pending invitation for an email + When an admin invites the same email again + Then the API responds 409 + + @site-invitations/by-token @api + Scenario: An invitation can be fetched by token + Given an existing invitation + When it is requested by token + Then the invitation is returned diff --git a/spec/storages.feature b/spec/storages.feature new file mode 100644 index 00000000..4eb77578 --- /dev/null +++ b/spec/storages.feature @@ -0,0 +1,63 @@ +Feature: Storages + Admins configure S3-compatible storage backends. Uploads later flow directly to + the selected backend via presigned URLs. + + @storages/auth-required @api + Scenario: Storage management requires an authenticated admin + Given an unauthenticated request + When it calls the admin storages API + Then the API responds 401 + + @storages/admin-only @api + Scenario: Non-admins cannot manage storages + Given an authenticated non-admin user + When they call the admin storages API + Then the API responds 403 + + @storages/list @api + Scenario: Admins list configured storages + Given configured storages + When an admin lists storages + Then every configured storage is returned + + @storages/create @api + Scenario: Admins create a storage + Given an authenticated admin + When they POST a valid storage config + Then the storage is created and returned + + @storages/community-limit @api + Scenario: The Community edition caps the number of storages + Given the Community storage limit is reached and storages_unlimited is not licensed + When an admin creates another storage + Then the API responds 402 feature_not_available + + @storages/detail @api + Scenario: Admins read a single storage + Given an existing storage + When an admin requests it by id + Then its detail is returned + + @storages/update @api + Scenario: Admins update a storage + Given an existing storage + When an admin updates its fields + Then the changes are persisted + + @storages/delete @api + Scenario: Admins delete an unused storage + Given an existing storage referenced by no files + When an admin deletes it + Then it is removed + + @storages/delete-in-use @api + Scenario: A storage referenced by files cannot be deleted + Given a storage referenced by existing files + When an admin deletes it + Then the API responds 409 + + @storages/select-active @api + Scenario: Uploads pick an active storage with available capacity + Given several storages of the requested mode + When the platform selects a storage + Then it returns the oldest active one below capacity and skips full or disabled ones diff --git a/spec/system.feature b/spec/system.feature new file mode 100644 index 00000000..0af28187 --- /dev/null +++ b/spec/system.feature @@ -0,0 +1,51 @@ +Feature: System options + Admins manage instance-wide system options (public and private), default quota + values, captcha, and read instance info + the release changelog. + + @system/option-not-found @api + Scenario: An unknown option key returns 404 + Given no option for a key + When it is requested + Then the API responds 404 + + @system/admin-crud @api + Scenario: Admins manage options through their lifecycle + Given an admin + When they create, read, update, and delete an option + Then public/private visibility is honored throughout + + @system/mutations-require-admin @api + Scenario: Option mutations require an admin + Given an unauthenticated request + When it mutates an option + Then it is rejected + + @system/validate-org-quota @api + Scenario: Default org quota values are validated + Given an admin + When they set an invalid default organization quota + Then it is rejected + + @system/validate-traffic-quota @api + Scenario: Default monthly traffic quota values are validated + Given an admin + When they set an invalid default monthly traffic quota + Then it is rejected + + @system/instance-info-admin-only @api + Scenario: Instance info is admin-only + Given the instance-info endpoint + When a non-admin requests it + Then access is denied + + @system/changelog-admin-only @api + Scenario: Release version and changelog are admin-only + Given the changelog endpoint + When a non-admin requests it + Then access is denied + + @system/captcha-secret-private @api + Scenario: Captcha secret stays private and cannot be enabled prematurely + Given an admin + When they read captcha config or enable captcha before keys exist + Then the secret stays private and premature enabling is rejected diff --git a/spec/teams-admin.feature b/spec/teams-admin.feature new file mode 100644 index 00000000..0953e103 --- /dev/null +++ b/spec/teams-admin.feature @@ -0,0 +1,45 @@ +Feature: Team administration + Site admins review team orgs (usage, members, owner) — excluding personal + spaces — and manage per-team storage entitlement grants. + + @teams-admin/admin-only @api + Scenario: Team administration requires an admin + Given a non-admin user + When they call the admin teams API + Then the API responds 403 + + @teams-admin/list @api + Scenario: Admins list team orgs + Given team and personal orgs + When an admin lists teams + Then only team orgs are returned with usage, members, and owner + + @teams-admin/detail @api + Scenario: Admins read a team's detail + Given a team org + When an admin requests it + Then its detail is returned + + @teams-admin/detail-not-found @api + Scenario: A missing or personal org has no team detail + Given a missing or personal org id + When an admin requests team detail + Then the API responds 404 + + @teams-admin/entitlement-lifecycle @api + Scenario: Admins grant, list, and revoke a team entitlement + Given a team org + When an admin grants, lists, and revokes a storage entitlement + Then each step succeeds + + @teams-admin/update-entitlement @api + Scenario: Admins update a team entitlement + Given an existing admin grant + When an admin updates its bytes + Then the change is persisted + + @teams-admin/entitlement-guards @api + Scenario: Team entitlement operations are guarded + Given an unknown org or a non-admin caller + When a team entitlement operation is attempted + Then it responds 404 or 403 respectively diff --git a/spec/teams.feature b/spec/teams.feature new file mode 100644 index 00000000..790de07f --- /dev/null +++ b/spec/teams.feature @@ -0,0 +1,75 @@ +Feature: Teams + Users own a personal org and may belong to shared team orgs. Owners issue invite + links; invitees join via token. Each org exposes an activity feed to its members. + + @teams/invite-token-missing @api + Scenario: Reading invite info without a token fails + Given a request to the invite-info endpoint with no token + When it is called + Then the API responds 400 + + @teams/invite-info-public @api + Scenario: Invite info is readable without authentication + Given a valid invite token + When the invite info is requested without auth + Then the invite info is returned + + @teams/create-invite @api + Scenario: An owner creates an invite link + Given an authenticated team owner + When they create an invite link + Then a 201 with the invite token is returned + + @teams/list-pending-empty @api + Scenario: A team with no pending invitations lists none + Given a team with no pending invitations + When its owner lists invitations + Then an empty list is returned + + @teams/join @api + Scenario: A user joins a team with a valid token + Given a valid invite token + When an authenticated user joins + Then they become a member of the team + + @teams/join-already-member @api + Scenario: Joining a team twice is rejected + Given a user who is already a member + When they join again with a valid token + Then the API responds 409 + + @teams/access-non-member @api + Scenario: Non-members cannot access a team org + Given an authenticated user who is not a member of a non-personal org + When they access that org + Then the API responds 403 + + @teams/access-personal-public @api + Scenario: Personal orgs are visible to any authenticated user + Given any authenticated user + When they access a personal org + Then the API responds 200 + + @teams/access-team-member @api + Scenario: Team members can access their team org + Given a member of a non-personal team org + When they access that org + Then the API responds 200 + + @teams/activity-feed @api + Scenario: An org exposes its activity feed + Given an org with recorded activity events + When a member reads the activity feed + Then activity items with actor info are returned + + @teams/activity-newest-first @api + Scenario: Activity is ordered newest first + Given several activity events + When a member reads the activity feed + Then items are ordered newest first + + @teams/activity-pagination @api + Scenario: The activity feed paginates + Given more activity events than one page + When a member requests a page + Then the requested page and pageSize are honored diff --git a/spec/users.feature b/spec/users.feature new file mode 100644 index 00000000..393b6374 --- /dev/null +++ b/spec/users.feature @@ -0,0 +1,99 @@ +Feature: User administration + Admins list, filter, disable, and delete users, and manage per-user storage + entitlement grants against each user's personal org. + + @users/auth-required @api + Scenario: User administration requires authentication + Given an unauthenticated request + When it calls the admin users API + Then the API responds 401 + + @users/admin-only @api + Scenario: Non-admins cannot administer users + Given an authenticated non-admin user + When they call the admin users API + Then the API responds 403 + + @users/list @api + Scenario: Admins list users with pagination + Given several users + When an admin lists users + Then a paginated list of users is returned + + @users/quota-personal-org @api + Scenario: A listed user shows their personal-org quota + Given a user with a personal org + When an admin lists users + Then the user's quota reflects their personal organization + + @users/quota-entitlements @api + Scenario: A listed user's quota includes active entitlements + Given a user with an active plan and extra storage entitlements + When an admin lists users + Then the quota total is computed from plan plus entitlements + + @users/filter @api + Scenario: Admins filter users + Given users with various names, usernames, and emails + When an admin filters the list + Then only matching users and the filtered totals are returned + + @users/disable @api + Scenario: Admins disable a user + Given an active user + When an admin sets their status to disabled + Then the user is disabled + + @users/invalid-status @api + Scenario: Setting an invalid status is rejected + Given an existing user + When an admin sets an invalid status + Then the API rejects it + + @users/patch-missing @api + Scenario: Updating a missing user returns 404 + Given a user id that does not exist + When an admin updates it + Then the API responds 404 + + @users/disabled-session-rejected @api + Scenario: A disabled user's existing session is rejected + Given a user disabled mid-session + When they make an authenticated request + Then the auth middleware rejects it + + @users/delete @api + Scenario: Admins delete a user + Given an existing user + When an admin deletes them + Then the user is removed + + @users/batch @api + Scenario: Admins batch-toggle user status + Given several users + When an admin batch-disables and enables them + Then each user's status is updated + + @users/grant-entitlement @api + Scenario: Admins grant a storage entitlement + Given a user with a personal org + When an admin grants a storage entitlement + Then the entitlement is recorded against the personal org + + @users/update-entitlement @api + Scenario: Admins update an admin-granted entitlement + Given an existing admin grant + When an admin updates it + Then the changes are persisted + + @users/revoke-entitlement @api + Scenario: Admins revoke an admin-granted entitlement + Given an existing admin grant + When an admin revokes it + Then the entitlement is removed + + @users/entitlement-source-guard @api + Scenario: Only admin-granted entitlements can be edited + Given an entitlement that was not admin-granted + When an admin tries to update or revoke it + Then the API rejects the operation diff --git a/spec/webdav.feature b/spec/webdav.feature new file mode 100644 index 00000000..9a0418cd --- /dev/null +++ b/spec/webdav.feature @@ -0,0 +1,148 @@ +Feature: WebDAV + Users mount their drive over WebDAV (Class 2) using an API key via Basic Auth. + The endpoint speaks PROPFIND/PROPPATCH/GET/PUT/MKCOL/MOVE/COPY/DELETE/LOCK/UNLOCK, + scoped to the user's org, metering served traffic and enforcing quota + locks. + + @webdav/auth @api + Scenario: WebDAV requires a valid API key + Given missing or insufficient API keys and a session cookie + When a WebDAV request is made + Then it is rejected and the session cookie is not accepted + + @webdav/auth-key-scope @api + Scenario: Org-bound image-hosting keys are rejected + Given an org-bound image-hosting API key + When it is used for WebDAV Basic Auth + Then it is rejected + + @webdav/propfind @api + Scenario: PROPFIND lists the hierarchy + Given a mounted drive + When PROPFIND is issued + Then the mount root, workspace root, and folder children are listed + + @webdav/propfind-workspaces @api + Scenario: PROPFIND hides non-member workspaces + Given several workspaces + When PROPFIND lists the mount root + Then only member workspaces are shown + + @webdav/propfind-modes @api + Scenario: PROPFIND supports its query modes + Given a resource + When PROPFIND uses prop, propname, allprop, and explicit depths + Then each mode is honored and depth infinity is rejected + + @webdav/proppatch @api + Scenario: PROPPATCH manages dead properties + Given a resource + When dead properties are set and removed via PROPPATCH + Then later PROPFIND reflects the change + + @webdav/get @api + Scenario: GET and HEAD serve a file + Given a file + When GET and HEAD are issued + Then GET returns bytes and HEAD returns coherent headers + + @webdav/get-traffic @api + Scenario: GET meters traffic, HEAD does not + Given a file + When GET and HEAD are issued + Then only GET consumes WebDAV traffic + + @webdav/get-range @api + Scenario: GET supports byte ranges + Given a file + When a range request is made + Then valid ranges are served and invalid ranges rejected + + @webdav/etag-preconditions @api + Scenario: GET honors ETag preconditions + Given a file + When conditional requests use ETag + Then preconditions are honored and the ETag changes after overwrite + + @webdav/options @api + Scenario: OPTIONS advertises DAV methods + Given the endpoint + When OPTIONS is issued + Then the supported DAV methods are advertised + + @webdav/put-create @api + Scenario: PUT creates a file + Given a writable path + When PUT writes bytes + Then a file matter is created through the configured storage + + @webdav/put-update @api + Scenario: PUT updates a file and rejects collection writes + Given an existing file and a collection + When PUT targets each + Then the file is updated and the collection write is rejected + + @webdav/put-rollback @api + Scenario: A failed PUT rolls back its quota reservation + Given a storage write that fails + When PUT is attempted + Then the quota reservation is rolled back + + @webdav/mkcol @api + Scenario: MKCOL creates a folder + Given a writable parent + When MKCOL is issued + Then a folder matter is created + + @webdav/mkcol-guards @api + Scenario: MKCOL guards existing targets and missing parents + Given an existing target or a missing parent + When MKCOL is issued + Then it is rejected + + @webdav/org-scope @api + Scenario: Mutations stay within org scope and DELETE trashes + Given resources across orgs + When MOVE, COPY, and DELETE are issued + Then they stay within the org and DELETE trashes instead of purging + + @webdav/copy-recursive @api + Scenario: COPY recurses and rejects copying into a descendant + Given a collection + When COPY is issued + Then it copies recursively and rejects copying into its own descendant + + @webdav/move-descendants @api + Scenario: MOVE keeps descendant paths consistent + Given a collection + When MOVE is issued + Then descendant paths stay consistent and descendant moves are rejected + + @webdav/move-overwrite @api + Scenario: MOVE honors the Overwrite header + Given an existing destination + When MOVE is issued with Overwrite + Then the header is honored + + @webdav/copy-rollback @api + Scenario: A failed COPY rolls back its quota reservation + Given a storage copy that fails + When COPY is attempted + Then the quota reservation is rolled back + + @webdav/lock-preconditions @api + Scenario: Write methods enforce If and lock preconditions + Given a locked resource + When a write method is issued + Then If and lock preconditions are enforced before mutation + + @webdav/lock-unlock @api + Scenario: LOCK and UNLOCK manage Class 2 locks + Given a resource + When LOCK and UNLOCK are issued + Then Class 2 lock state is exposed and write tokens enforced + + @webdav/path-validation @api + Scenario: Malformed paths are rejected + Given a path with traversal, empty segments, or encoded separators + When any method is issued + Then it is rejected diff --git a/tsconfig.depcruise.json b/tsconfig.depcruise.json new file mode 100644 index 00000000..85d3c722 --- /dev/null +++ b/tsconfig.depcruise.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + }, + "include": ["server/**/*.ts", "shared/**/*.ts", "src/**/*.ts", "src/**/*.tsx", "workers/**/*.ts"] +} diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts index 7ca76003..0f532949 100644 --- a/workers/bootstrap.ts +++ b/workers/bootstrap.ts @@ -1,10 +1,11 @@ +import { createArchiveJobsGateway } from '../server/adapters/gateways/archive-jobs' +import { createShareRepo } from '../server/adapters/repos/share' import { createApp } from '../server/app' import type { Auth } from '../server/auth' import { createAuth } from '../server/auth' import { createCloudflarePlatform } from '../server/platform/cloudflare' import { platformContext } from '../server/platform/context' -import { type ArchiveJobMessage, runArchiveJobMessage } from '../server/services/archive-jobs' -import { resolveShareByToken } from '../server/services/share' +import type { ArchiveJobMessage } from '../server/usecases/ports' import { DirType } from '../shared/constants' import { handleScheduled } from './scheduled' @@ -62,8 +63,9 @@ export default { async queue(batch: MessageBatch, env: Env): Promise { const platform = createCloudflarePlatform(env) + const archiveJobs = createArchiveJobsGateway(platform) for (const message of batch.messages) { - await runArchiveJobMessage(platform, message.body) + await archiveJobs.runMessage(message.body) message.ack() } }, @@ -87,7 +89,7 @@ async function fetchShareMeta( } try { - const resolved = await resolveShareByToken(platform.db, token) + const resolved = await createShareRepo(platform.db).resolveByToken(token) if (resolved.status !== 'ok') return fallback if (resolved.share.kind !== 'landing') return fallback diff --git a/workers/scheduled.ts b/workers/scheduled.ts index cfd7e7ec..5833350c 100644 --- a/workers/scheduled.ts +++ b/workers/scheduled.ts @@ -1,12 +1,13 @@ // CF Workers scheduled() handler. +import { createQuotaRepo } from '../server/adapters/repos/quota' +import { createDeps } from '../server/composition' import { createCloudflarePlatform } from '../server/platform/cloudflare' -import { syncPendingCloudTrafficReports } from '../server/services/cloud-traffic-metering' -import { resetExpiredTrafficQuotas } from '../server/services/effective-quota' -import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/services/instance-telemetry' -import { runLicensingRefresh } from '../server/services/licensing-refresh-runner' -import { syncPendingRemoteDownloadUsageReports } from '../server/services/remote-download-usage' -import { purgeExpiredTrash, resolveTrashRetentionDays } from '../server/services/trash-retention' +import { syncPendingCloudTrafficReports } from '../server/usecases/cloud-traffic-metering' +import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/usecases/instance-telemetry' +import { runLicensingRefresh } from '../server/usecases/licensing-refresh-runner' +import { syncPendingRemoteDownloadUsageReports } from '../server/usecases/remote-download-usage' +import { purgeExpiredTrash, resolveTrashRetentionDays } from '../server/usecases/trash-retention' import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants' // Subset of the worker Env used by the scheduled handler. @@ -30,26 +31,26 @@ function envAllowsIp(value: string | undefined): boolean { export async function handleScheduled(event: ScheduledTrigger, env: ScheduledEnv): Promise { const platform = createCloudflarePlatform(env) + const deps = createDeps(platform) const cloudBaseUrl = env.ZPAN_CLOUD_URL ?? ZPAN_CLOUD_URL_DEFAULT if (event.cron === TRAFFIC_SYNC_CRON) { - await syncPendingCloudTrafficReports({ db: platform.db, cloudBaseUrl }) - await syncPendingRemoteDownloadUsageReports({ db: platform.db, cloudBaseUrl }) + await syncPendingCloudTrafficReports(deps, { cloudBaseUrl }) + await syncPendingRemoteDownloadUsageReports(deps, { cloudBaseUrl }) return } if (event.cron === QUOTA_RESET_CRON) { - await resetExpiredTrafficQuotas(platform.db) + await createQuotaRepo(platform.db).resetExpiredTrafficQuotas() return } if (event.cron === TRASH_PURGE_CRON) { - await purgeExpiredTrash(platform.db, resolveTrashRetentionDays(env.ZPAN_TRASH_RETENTION_DAYS)) + await purgeExpiredTrash(deps, resolveTrashRetentionDays(env.ZPAN_TRASH_RETENTION_DAYS)) return } if (event.cron === INSTANCE_TELEMETRY_CRON) { - await reportInstanceTelemetry({ - db: platform.db, + await reportInstanceTelemetry(deps, { config: { allowIp: envAllowsIp(env.ZPAN_TELEMETRY_ALLOW_IP), }, @@ -63,5 +64,5 @@ export async function handleScheduled(event: ScheduledTrigger, env: ScheduledEnv return } - await runLicensingRefresh(platform.db, cloudBaseUrl) + await runLicensingRefresh(deps, cloudBaseUrl) }