Files
zpan/server/test/setup.ts
T
Jasper VanandClaude Opus 4.8 191ee0a07d refactor(server): clean architecture migration (hono-cf-clean-arch) (#433)
* refactor(server): rename routes/ to http/ (clean-arch step 1)

The HTTP delivery layer was already split per-resource; align the directory
name with the hono-cf-clean-arch standard. Pure mechanical move via git mv;
updates the three server-side importers (app.ts, image-hosting-domain
middleware, openapi/downloader). No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): add clean-arch backbone + migrate activity to a repo

Introduce the composition root and dependency-injection seam:
- usecases/ports.ts (barrel) + usecases/ports/<resource>.ts: framework-free
  port interfaces and DTOs
- usecases/deps.ts: the Deps aggregate consumed via c.get('deps')
- composition.ts: createDeps(platform) — the only place adapters are built
- app.ts sets deps in request context after platform middleware

First adapter: adapters/repos/activity.ts (ActivityRepo) replaces
services/activity.ts. All 14 call sites rewired (routes use
c.get('deps').activity.*; auth.ts and transitional services construct the repo
from db). DTOs are now plain shapes, not drizzle $inferSelect.

Behavior-preserving: typecheck + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract StorageRepo + migration tracker

services/storage.ts -> adapters/repos/storage.ts (StorageRepo). All 14 callers
rewired (http/middleware via c.get('deps').storages.*; transitional services via
createStorageRepo(db)). Port DTO reuses the shared Storage contract with Date
timestamps; the S3-credential 'Storage' type alias across 9 files now points at
StorageRecord. Data-layer test moved next to the repo.

Adds docs/clean-arch-migration.md as the living progress tracker.

typecheck + lint + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract Profile/Announcement/Notification repos

- profile -> ProfileRepo; the pure buildBreadcrumb moves to domain/breadcrumb.ts
- announcement -> AnnouncementRepo; notification -> NotificationRepo
- All callers rewired (routes via c.get('deps').*; auth.ts + services via
  create<X>Repo(db)); data-layer tests moved next to their repos
- Test infra: createApp accepts an optional deps; createTestApp returns deps so
  tests fake a port by spying on testApp.deps.* (events SSE failure test no
  longer spies the service module)

typecheck + lint + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract OrgRepo (authz) + InviteRepo

- org -> OrgRepo (findPersonalOrg/getMemberRole/canReadOrg/canWriteToOrg/
  isPersonalOrg); rewired across 4 routes + 2 auth middlewares + auth.ts
- invite -> InviteRepo; rewired invite-codes route + auth.ts
- data/unit tests for org & invite moved next to their repos

typecheck + lint + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract BackgroundJobRepo (+ BackgroundJobError to ports)

background-jobs -> adapters/repos/background-job.ts. The BackgroundJobError
(caught by http for status mapping) moves to usecases/ports per the standard.
Rewired: background-jobs route + events SSE (deps) + archive-processing
(transitional repo). Unit + data tests relocated.

typecheck + lint + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract QuotaRepo from effective-quota

The foundational quota leaf. effective-quota.ts -> adapters/repos/quota.ts
(QuotaRepo); the pure currentTrafficPeriod moves to domain/quota.ts; DTOs
(EffectiveQuota, CurrentStoragePlan) move to ports. Rewired 14 callers
(http -> deps.quota; services/auth/entry-node/workers.scheduled -> createQuotaRepo).
scheduled-worker test now mocks the adapter (createQuotaRepo) instead of the
service module.

typecheck + lint + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract TeamRepo + TeamInviteRepo

team -> adapters/repos/team.ts (TeamRepo; composes QuotaRepo for quota totals);
team-invite -> adapters/repos/team-invite.ts. teams-admin + teams routes use
c.get('deps').{teams,teamInvites}. Data tests relocated.

typecheck + lint + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build(arch): enforce clean architecture via dependency-cruiser (ratchet) in CI

Adds .dependency-cruiser.cjs with the full hono-cf-clean-arch rule set and wires
pnpm lint:arch into CI. The drizzle-only-in-repos rule uses a shrinking
MIGRATION_PENDING allowlist so it passes today while still enforcing every
already-migrated layer; each future migration commit removes an entry. platform/
(Database driver type) and auth.ts are permanent named exceptions.

Currently green: 222 modules / 926 deps, 0 violations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): combine user + org-entitlements into UserAdminRepo

Resolves the pre-existing user <-> org-entitlements import cycle by merging both
into adapters/repos/user-admin.ts (UserAdminRepo); shared types (UserWithOrg,
QuotaEntitlementItem, UserOperationFailure, entitlement inputs) move to ports.
users + teams-admin routes use c.get('deps').userAdmin.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract SiteInvitationRepo

site-invitations -> adapters/repos/site-invitations.ts. Route uses
c.get('deps').siteInvitations; the email helper now receives siteName from the
handler (http stays out of adapters); auth.ts uses the repo. Result-type unions
moved to ports.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cf): fix storages.cf-test seed after StorageRepo extraction

cf-tests are excluded from typecheck; biome had pruned the transiently-unused
createStorageRepo import during the storage migration. Restore the import and
convert the platform.db seed calls. test:cf green (57 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): introduce BDD-lite spec/ + spec<->test traceability lint

Adds the standard's product-spec layer:
- spec/*.feature (Gherkin, no Cucumber runner) — one per capability, scenarios
  tagged @<capability>/<slug> + layer; spec/README.md documents the convention
- [spec: <id>] breadcrumbs on home tests
- scripts/lint-spec.mjs + pnpm lint:spec (wired into CI): every scenario id must
  have a referencing test and every breadcrumb must match a scenario

Specced: storages, announcements, notifications, invite-codes, site-invitations
(41 scenarios, all traced). Specs grow per capability as the migration proceeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract changelog + cf-custom-hostnames providers

Establishes adapters/providers/. changelog (GitHub releases/CHANGELOG) and
cf-custom-hostnames (CF for SaaS) move to adapters/providers/ behind
ChangelogProvider / CfHostnamesProvider ports (CfConflictError -> ports).
system + ihost-config routes use c.get('deps').{changelog,cfHostnames}.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): move db-transaction -> db/, path-template -> lib/

Two framework-free utilities leave services/ for their proper homes:
db/transaction.ts (the drizzle batch/transaction helper) and lib/path-template.ts
(object-key builder). Importers updated.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): migrate licensing subsystem drizzle to repos

license-state -> adapters/repos/license-binding.ts (LicenseBindingRepo);
instance-id + instance-info DB reads -> adapters/repos/instance.ts (InstanceRepo).
licensing/ (has-feature, refresh, entitlement, instance-info) now uses the repos
and imports no drizzle, so ^server/licensing leaves the dependency-cruiser ratchet.
licensing-admin route uses c.get('deps').{licenseBinding,instance}; service callers
construct the repos; instance-telemetry test mocks the adapter.

typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): move S3Service to adapters/gateways behind S3Gateway port

Establishes adapters/gateways/ + deps.s3. S3Service -> adapters/gateways/s3.ts
(implements S3Gateway; S3StorageCredentials -> ports). A thin services/s3.ts
re-export shim keeps the http routes (objects/webdav/ihost/share-utils) and the
21 prototype-spy tests working unchanged until those routes migrate to deps.s3;
s3-dependent services can now move to usecases using deps.s3.

typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): drain inline drizzle from me route (avatar -> ProfileRepo)

ProfileRepo gains setAvatar; the /api/me avatar handlers use c.get('deps').profiles
instead of inline user-table updates. 'me' leaves the dependency-cruiser ratchet.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): drain inline drizzle from quotas route (-> QuotaRepo.listOrgQuotaOverview)

The admin quota-overview join moves into QuotaRepo; the route uses
c.get('deps').quota. 'quotas' leaves the ratchet.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): SystemOptionsRepo drains auth-providers/system/email-config routes

New adapters/repos/system-options.ts (key-value access to systemOptions) + deps.systemOptions.
auth-providers, system, email-config routes drop inline drizzle and use
c.get('deps').systemOptions; all three leave the ratchet.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): drain inline drizzle from teams route (logo -> TeamRepo.setLogo)

TeamRepo gains setLogo; teams route uses c.get('deps').teams for logo set/clear
and drops its dead db locals. 'teams' leaves the ratchet.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): drain inline drizzle from ihost-config (-> ImageHostingConfigRepo)

New adapters/repos/image-hosting-config.ts + deps.imageHostingConfigs. The ihost-config
route's custom-domain CRUD uses c.get('deps').imageHostingConfigs (cf-hostnames already
via deps). 'ihost-config' leaves the ratchet.

typecheck + lint + lint:arch + 3807 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): loadBindingState -> usecase, hasFeature/effectiveFeatures -> domain

Finishes the feature-gate path: domain/licensing.ts (pure hasFeature/effectiveFeatures),
usecases/licensing.ts (loadBindingState(deps) using LicenseBindingRepo + cert verify).
licensing/has-feature.ts deleted. Rewired 10 callers (routes/middleware via
c.get('deps'); services via createLicenseBindingRepo(db)). Tests retargeted to the
new modules (domain + usecases licensing).

typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): extract StorageUsageRepo + storage-usage reservation usecase

The quota-reservation crown dependency. adapters/repos/storage-usage.ts
(StorageUsageRepo: rollbackReservations + reconcile); usecases/storage-usage.ts
(reserveStorageUsage/withStorageUsageReservation/StorageUsageMutationContext taking
{quota,storageUsage} deps); StorageQuotaExceededError -> ports. Rewired 9 callers
(objects/webdav/ihost routes via c.get('deps'); matter/image-hosting/archive/purge/
save-to-drive via constructed repos). Unblocks the matter/image-hosting clusters.

typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): migrate 5 leaf service clusters to clean-arch (parallel wave)

Extracted 7 services via parallel agents on file-disjoint components:
- instance-telemetry -> usecases/instance-telemetry (reuses instance + systemOptions ports)
- image-upload -> adapters/gateways/image-upload (ImageUpload port, deps.imageUpload)
- archive-jobs -> adapters/gateways/archive-jobs (ArchiveJobsGateway, deps.archiveJobs)
- zip-compress + zip-extract -> adapters/gateways/zip + adapters/repos/zip (ZipGateway + ZipPlanRepo)
- object-upload-sessions -> adapters/repos/object-upload-session (ObjectUploadSessionRepo)
- purge -> usecases/purge (pure usecase over existing s3/storages/storageUsage)

Routes (objects/teams/me/internal/background-jobs) now reach these via c.get('deps');
entry files + workers build deps via createDeps(platform). Barrels wired by hand.

typecheck + lint:arch (240 modules) + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): add quotas/profile/licensing feature specs + traceability

29 new scenarios traced to existing integration tests via [spec: id] breadcrumbs.
lint:spec: 70 scenarios, all covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): migrate auth/webdav/cloud/branding/image-hosting clusters (parallel wave 2)

17 services extracted via 5 parallel agents on file-disjoint components:
- auth-account: email->EmailGateway, share-notification->ShareNotificationRepo,
  member-count->MemberCountRepo, captcha->domain+usecase, signup-mode/team-count->usecases
- webdav-middleware: api-keys/download-tokens gateways, webdav-state/webdav-path repos,
  webdav-xml->domain (pure)
- cloud: licensing-cloud->LicensingCloudGateway, cloud-store/cloud-traffic-report/
  remote-download-usage repos (cloud-traffic-metering + licensing-refresh-runner folded in)
- branding: pure usecase over existing deps (no new port)
- image-hosting: ImageHostingRepo

12 new deps fields wired by hand. WebDavMatterRow DTO moved into the webdav-path port
(was importing services/matter, which cycled through the ports barrel); domain WebDavMatter
dirtype widened to number|null to match the nullable column. Ratchet shrunk: ihost.ts +
middleware/image-hosting-domain.ts no longer touch drizzle. services/ now 26->9 (matter crown).

typecheck + lint:arch (261 modules, no cycles) + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): add users/audit/teams/avatar/background-jobs/events/health specs

64 new scenarios traced to existing integration tests. lint:spec: 133 scenarios, all covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): migrate share/save-to-drive/archive-processing/trash-retention (parallel wave 3)

- share -> ShareRepo (+ domain/share, transitional ShareMatterRow DTO); shares.ts now
  holds ZERO drizzle (dropped from the ratchet)
- save-to-drive -> pure usecase over deps (s3/storages/storageUsage/quota/activity/share)
- archive-processing -> usecase + ArchiveTargetFolderRepo (archive-jobs gateway self-assembles
  its deps subset from platform to avoid a composition cycle)
- trash-retention -> pure usecase

purge gains deps.share for share cascade-delete. 2 new deps fields wired. services/ now 9->5
(matter, matter-name-conflict, downloads, s3 shim, site-public-origin remain).

typecheck + lint:arch (265 modules, no cycles) + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): add branding/email-config/auth-providers/system/image-hosting/webdav/quota-store specs

128 new scenarios traced to existing integration tests. lint:spec: 261 scenarios, all covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): migrate the matter keystone + site-public-origin (wave 4)

The crown. matter (644 lines, 17 exports) -> adapters/repos/matter.ts (MatterRepo: full
drizzle CRUD + conflict resolution) + usecases/matter.ts (confirmUpload quota-guarded) +
usecases/ports/matter.ts (Matter DTO + NameConflictError); matter-name-conflict -> domain.
Fan-in of 10 rewired: objects/shares/trash routes now hold ZERO matter drizzle (via deps.matter);
webdav + archive-processing/purge/save-to-drive/trash-retention usecases + zip/webdav-path repos
repointed. site-public-origin -> domain (pure helpers) + usecase over deps.systemOptions.

services/ now 5->2 (only downloads + the s3 shim remain). 1 new deps field (matter).

typecheck + lint:arch (268 modules, no cycles) + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): add redirect + download-tasks specs

44 new scenarios traced to existing integration tests. lint:spec: 305 scenarios, all covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): migrate downloads (remote-download) cluster (wave 5)

downloads/{core,mappers,types} (915 lines) -> adapters/repos/{downloader,download-task}
(DownloaderRepo + DownloadTaskRepo) + usecases/downloads.ts (assignment + task state
machine + remote-download credit billing) + usecases/ports/downloads.ts (DownloadError +
DTOs). Rewired download-tasks/downloaders/events routes + objects.ts upload handlers to
c.get('deps'). 2 new deps fields. services/ now down to ONLY the s3 shim.

typecheck + lint:arch (268 modules) + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): add shares spec (32 scenarios)

lint:spec: 337 scenarios, all covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): delete the s3 shim — services/ is empty, clean-arch complete

Routed all 20 S3 call-sites in http (objects/webdav routes + share-utils consumers
shares/redirect/ihost/image-hosting-domain) onto c.get('deps').s3; webdav's no-c helpers
take an S3Gateway param. Repointed 17 test files off the shim onto adapters/gateways/s3.
Deleted server/services/s3.ts — server/services/ is now empty and gone.

Ratchet: dropped ^server/services (fully migrated); no-circular now fully enforced with
no path exemptions. MIGRATION_PENDING is down to 2 deliberately-deferred files
(http/webdav.ts listDescendants, middleware/auth.ts session lookup).

Also adds the objects spec (39 scenarios) -> 376 scenarios across 26 capabilities.

Final gates: typecheck + lint:arch (267 modules, no cycles) + lint:spec (376) + lint
+ 3810 tests + 57 cf-tests all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): migrate the last 2 ratchet files — architecture fully locked

webdav.ts + middleware/auth.ts were the last files touching drizzle outside repos.
- WebDAV: listDescendants/PROPPATCH-touch/PUT-overwrite/COPY-rollback + Basic-Auth username
  check moved to MatterRepo.{listActiveDescendants,trashByIds,restoreActiveByIds,touch,applyUpload}
  + UserAdminRepo.{isBanned,matchesUsername}. webdav.ts now imports no drizzle.
- Auth middleware: disabled-user (banned) check -> deps.userAdmin.isBanned.

Ratchet (MIGRATION_PENDING) is now empty and removed. no-circular + drizzle-only-in-repos
are fully enforced with zero exemptions; only platform/, test/, auth.ts remain as permanent
named exceptions. New methods covered by existing real-D1 webdav/auth integration tests.

typecheck + lint:arch (267 modules) + lint:spec (376) + lint + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(spec): spec the 4 remaining admin/auth capabilities

Closes the spec gaps for capabilities that had routes+tests but no .feature:
image-hosting-config (domain/CF custom-hostname admin), licensing-admin (cloud
pairing/binding/refresh), teams-admin (team admin + entitlements), auth-username
(username sign-up). 42 new scenarios traced to existing integration tests.

lint:spec: 418 scenarios across 30 capabilities, all covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(matter): listActiveDescendants uses exact-prefix (SUBSTR) not LIKE

Folder names can contain '_'/'%', which LIKE treats as wildcards and would
over-match descendants in WebDAV recursive COPY/MOVE. Reuse the repo's existing
descendantParentCondition (SUBSTR), consistent with getDescendants/cascadeParentPath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): address review follow-ups (DTO dedupe, composition, dead locals)

- Dedupe transitional DTOs: ShareMatterRow + WebDavMatterRow -> the canonical Matter
  port DTO (removes hand-copied duplicates + schema-drift risk; no cycle reintroduced).
- composition.ts: hoist shared stateless instances (one s3/storages/systemOptions
  instead of constructing duplicates inline).
- Remove the 21 dead 'const db = c.get(platform).db' locals -> biome warning-free.

typecheck + lint:arch (267 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(server): dissolve server/licensing into domain + usecases layers

server/licensing/ was a feature-grouped dir outside the layer taxonomy — its 3
orchestration files imported adapters directly, escaping usecases-no-infrastructure.
Now classified + enforced:
- public-keys -> domain/license-keys (pure)
- verify + cloud-event-token -> usecases/license-certificate (paseto/zod crypto helpers)
- entitlement/instance-info/refresh -> deps-first usecases (license-entitlement,
  instance-info, license-refresh), using existing deps.{licenseBinding,instance,licensingCloud}

11 consumers rewired to deps; dead db param dropped from runLicensingRefresh. No barrel
changes. server/licensing/ deleted — every server file now sits in an enforced layer
(or a named exception: platform/test/auth.ts/lib/middleware).

typecheck + lint:arch (266 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:56:24 -04:00

669 lines
25 KiB
TypeScript

import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { generateKeys, sign } from 'paseto-ts/v4'
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 '../usecases/site-public-origin'
const AUTH_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
email_verified INTEGER NOT NULL DEFAULT 0,
image TEXT,
role TEXT,
banned INTEGER DEFAULT 0,
ban_reason TEXT,
ban_expires INTEGER,
username TEXT UNIQUE,
display_username TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
ip_address TEXT,
user_agent TEXT,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
impersonated_by TEXT,
active_organization_id TEXT
);
CREATE INDEX IF NOT EXISTS session_userId_idx ON session(user_id);
CREATE TABLE IF NOT EXISTS account (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
access_token TEXT,
refresh_token TEXT,
id_token TEXT,
access_token_expires_at INTEGER,
refresh_token_expires_at INTEGER,
scope TEXT,
password TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS account_userId_idx ON account(user_id);
CREATE TABLE IF NOT EXISTS verification (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS verification_identifier_idx ON verification(identifier);
CREATE TABLE IF NOT EXISTS organization (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
logo TEXT,
metadata TEXT,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE TABLE IF NOT EXISTS member (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS member_organizationId_idx ON member(organization_id);
CREATE INDEX IF NOT EXISTS member_userId_idx ON member(user_id);
CREATE TABLE IF NOT EXISTS invitation (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
expires_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
inviter_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS invitation_organizationId_idx ON invitation(organization_id);
CREATE INDEX IF NOT EXISTS invitation_email_idx ON invitation(email);
CREATE TABLE IF NOT EXISTS deviceCode (
id TEXT PRIMARY KEY,
device_code TEXT NOT NULL,
user_code TEXT NOT NULL,
user_id TEXT,
client_id TEXT,
scope TEXT,
status TEXT NOT NULL,
expires_at INTEGER NOT NULL,
last_polled_at INTEGER,
polling_interval INTEGER,
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
);
CREATE INDEX IF NOT EXISTS deviceCode_device_code_idx ON deviceCode(device_code);
CREATE INDEX IF NOT EXISTS deviceCode_user_code_idx ON deviceCode(user_code);
CREATE INDEX IF NOT EXISTS deviceCode_status_idx ON deviceCode(status);
`
const APP_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS matters (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
alias TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
type TEXT NOT NULL,
size INTEGER DEFAULT 0,
dirtype INTEGER DEFAULT 0,
parent TEXT NOT NULL DEFAULT '',
object TEXT NOT NULL DEFAULT '',
storage_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
trashed_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webdav_dead_properties (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
resource_path TEXT NOT NULL,
namespace TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS webdav_dead_properties_resource_prop_uniq
ON webdav_dead_properties(org_id, resource_path, namespace, name);
CREATE INDEX IF NOT EXISTS webdav_dead_properties_resource_idx
ON webdav_dead_properties(org_id, resource_path);
CREATE TABLE IF NOT EXISTS webdav_locks (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
org_id TEXT NOT NULL,
resource_path TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
depth TEXT NOT NULL DEFAULT 'infinity',
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS webdav_locks_resource_idx ON webdav_locks(org_id, resource_path);
CREATE INDEX IF NOT EXISTS webdav_locks_expires_idx ON webdav_locks(expires_at);
CREATE TABLE IF NOT EXISTS storages (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
mode TEXT NOT NULL,
bucket TEXT NOT NULL,
endpoint TEXT NOT NULL,
region TEXT NOT NULL DEFAULT 'auto',
access_key TEXT NOT NULL,
secret_key TEXT NOT NULL,
file_path TEXT NOT NULL DEFAULT '$UID/$RAW_NAME',
custom_host TEXT DEFAULT '',
capacity INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
egress_credit_billing_enabled INTEGER NOT NULL DEFAULT 0,
egress_credit_unit_bytes INTEGER NOT NULL DEFAULT 104857600,
egress_credit_per_unit INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS org_quotas (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
quota INTEGER NOT NULL DEFAULT 0,
used INTEGER NOT NULL DEFAULT 0,
traffic_quota INTEGER NOT NULL DEFAULT 0,
traffic_used INTEGER NOT NULL DEFAULT 0,
traffic_period TEXT NOT NULL DEFAULT '1970-01'
);
CREATE TABLE IF NOT EXISTS cloud_traffic_reports (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
period TEXT NOT NULL,
source TEXT NOT NULL,
source_id TEXT NOT NULL,
storage_id TEXT,
event_id TEXT NOT NULL,
bytes INTEGER NOT NULL,
unit_bytes INTEGER,
credits_per_unit INTEGER,
status TEXT NOT NULL,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS cloud_traffic_reports_event_uniq ON cloud_traffic_reports(event_id);
CREATE INDEX IF NOT EXISTS cloud_traffic_reports_org_period_idx ON cloud_traffic_reports(org_id, period);
CREATE INDEX IF NOT EXISTS cloud_traffic_reports_status_idx ON cloud_traffic_reports(status);
CREATE TABLE IF NOT EXISTS org_quota_entitlements (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
resource_type TEXT NOT NULL,
entitlement_type TEXT NOT NULL DEFAULT 'grant',
source TEXT NOT NULL,
source_id TEXT NOT NULL,
bytes INTEGER NOT NULL,
starts_at INTEGER NOT NULL,
expires_at INTEGER,
status TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS org_quota_entitlements_org_resource_idx
ON org_quota_entitlements(org_id, resource_type, status);
CREATE INDEX IF NOT EXISTS org_quota_entitlements_org_type_idx
ON org_quota_entitlements(org_id, resource_type, entitlement_type, status);
CREATE UNIQUE INDEX IF NOT EXISTS org_quota_entitlements_active_plan_uniq
ON org_quota_entitlements(org_id, resource_type, entitlement_type)
WHERE status = 'active' AND entitlement_type = 'plan';
CREATE UNIQUE INDEX IF NOT EXISTS org_quota_entitlements_source_resource_uniq
ON org_quota_entitlements(source, source_id, resource_type);
CREATE TABLE IF NOT EXISTS webhook_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
event_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload_hash TEXT NOT NULL,
raw_payload TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
created_at INTEGER NOT NULL,
processed_at INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS webhook_events_source_event_uniq ON webhook_events(source, event_id);
CREATE INDEX IF NOT EXISTS webhook_events_source_created_idx ON webhook_events(source, created_at);
CREATE INDEX IF NOT EXISTS webhook_events_status_idx ON webhook_events(status);
CREATE TABLE IF NOT EXISTS system_options (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
public INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS invite_codes (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL,
used_by TEXT,
used_at INTEGER,
expires_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS site_invitations (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
invited_by TEXT NOT NULL,
accepted_by TEXT,
accepted_at INTEGER,
revoked_by TEXT,
revoked_at INTEGER,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS site_invitations_email_idx ON site_invitations(email);
CREATE INDEX IF NOT EXISTS site_invitations_created_idx ON site_invitations(created_at);
CREATE INDEX IF NOT EXISTS site_invitations_expires_idx ON site_invitations(expires_at);
CREATE TABLE IF NOT EXISTS team_invite_links (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
organization_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
inviter_id TEXT NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS team_invite_links_token_unique ON team_invite_links(token);
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
target_name TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id);
CREATE TABLE IF NOT EXISTS shares (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
matter_id TEXT NOT NULL,
org_id TEXT NOT NULL,
creator_id TEXT NOT NULL,
password_hash TEXT,
expires_at INTEGER,
download_limit INTEGER,
views INTEGER NOT NULL DEFAULT 0,
downloads INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at);
CREATE TABLE IF NOT EXISTS share_recipients (
id TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
recipient_user_id TEXT,
recipient_email TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS share_recipients_share_id_idx ON share_recipients(share_id);
CREATE INDEX IF NOT EXISTS share_recipients_user_id_idx ON share_recipients(recipient_user_id);
CREATE TABLE IF NOT EXISTS image_hosting_configs (
org_id TEXT PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE,
custom_domain TEXT UNIQUE,
cf_hostname_id TEXT,
domain_verified_at INTEGER,
referer_allowlist TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS image_hostings (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
path TEXT NOT NULL,
storage_id TEXT NOT NULL,
storage_key TEXT NOT NULL,
size INTEGER NOT NULL,
mime TEXT NOT NULL,
width INTEGER,
height INTEGER,
status TEXT NOT NULL DEFAULT 'draft',
access_count INTEGER NOT NULL DEFAULT 0,
last_accessed_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS image_hostings_org_path_uniq ON image_hostings(org_id, path);
CREATE INDEX IF NOT EXISTS image_hostings_org_created_idx ON image_hostings(org_id, created_at);
CREATE INDEX IF NOT EXISTS image_hostings_token_idx ON image_hostings(token);
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
ref_type TEXT,
ref_id TEXT,
metadata TEXT,
read_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS notifications_user_created_idx ON notifications(user_id, created_at);
CREATE INDEX IF NOT EXISTS notifications_user_read_idx ON notifications(user_id, read_at);
CREATE TABLE IF NOT EXISTS background_jobs (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL,
target_folder TEXT,
target_path TEXT,
metadata TEXT,
input_bytes INTEGER NOT NULL DEFAULT 0,
output_bytes INTEGER NOT NULL DEFAULT 0,
processed_bytes INTEGER NOT NULL DEFAULT 0,
file_count INTEGER NOT NULL DEFAULT 0,
current_filename TEXT,
error_message TEXT,
result_metadata TEXT,
retryable INTEGER NOT NULL DEFAULT 0,
cancelable INTEGER NOT NULL DEFAULT 1,
retried_from_job_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER
);
CREATE INDEX IF NOT EXISTS background_jobs_org_created_idx ON background_jobs(org_id, created_at);
CREATE INDEX IF NOT EXISTS background_jobs_org_status_idx ON background_jobs(org_id, status);
CREATE INDEX IF NOT EXISTS background_jobs_org_type_idx ON background_jobs(org_id, type);
CREATE TABLE IF NOT EXISTS downloaders (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_jti TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'offline',
enabled INTEGER NOT NULL DEFAULT 1,
version TEXT NOT NULL DEFAULT 'unknown',
hostname TEXT NOT NULL DEFAULT 'unknown',
platform TEXT NOT NULL DEFAULT 'unknown',
arch TEXT NOT NULL DEFAULT 'unknown',
engine TEXT NOT NULL DEFAULT 'builtin',
capabilities TEXT NOT NULL DEFAULT '[]',
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
current_tasks INTEGER NOT NULL DEFAULT 0,
download_bps INTEGER NOT NULL DEFAULT 0,
upload_bps INTEGER NOT NULL DEFAULT 0,
free_disk_bytes INTEGER NOT NULL DEFAULT 0,
remote_download_credit_billing_enabled INTEGER NOT NULL DEFAULT 0,
remote_download_credit_unit_bytes INTEGER NOT NULL DEFAULT 104857600,
remote_download_credit_per_unit INTEGER NOT NULL DEFAULT 1,
last_heartbeat_at INTEGER,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS downloaders_token_jti_unique ON downloaders(token_jti);
CREATE INDEX IF NOT EXISTS downloaders_status_idx ON downloaders(status);
CREATE INDEX IF NOT EXISTS downloaders_enabled_idx ON downloaders(enabled);
CREATE INDEX IF NOT EXISTS downloaders_created_idx ON downloaders(created_at);
CREATE TABLE IF NOT EXISTS download_tasks (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
created_by_user_id TEXT NOT NULL,
source_type TEXT NOT NULL,
source_uri TEXT NOT NULL,
display_name TEXT,
target_folder TEXT NOT NULL DEFAULT '',
category TEXT,
tags TEXT NOT NULL DEFAULT '[]',
assigned_downloader_id TEXT,
status TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
billing_authorized_bytes INTEGER NOT NULL DEFAULT 0,
billing_charged_bytes INTEGER NOT NULL DEFAULT 0,
billing_charged_credits INTEGER NOT NULL DEFAULT 0,
billing_status TEXT NOT NULL DEFAULT 'none',
error_code TEXT,
error_message TEXT,
result_object_id TEXT,
runtime TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
assigned_at INTEGER,
started_at INTEGER,
finished_at INTEGER
);
CREATE INDEX IF NOT EXISTS download_tasks_org_created_idx ON download_tasks(org_id, created_at);
CREATE INDEX IF NOT EXISTS download_tasks_org_status_idx ON download_tasks(org_id, status);
CREATE INDEX IF NOT EXISTS download_tasks_org_category_idx ON download_tasks(org_id, category);
CREATE INDEX IF NOT EXISTS download_tasks_org_tags_idx ON download_tasks(org_id, tags);
CREATE INDEX IF NOT EXISTS download_tasks_downloader_idx ON download_tasks(assigned_downloader_id, status);
CREATE TABLE IF NOT EXISTS object_upload_sessions (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
object_id TEXT NOT NULL,
storage_id TEXT NOT NULL,
storage_key TEXT NOT NULL,
upload_id TEXT NOT NULL,
part_size INTEGER NOT NULL,
status TEXT NOT NULL,
created_by TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS object_upload_sessions_object_idx ON object_upload_sessions(org_id, object_id);
CREATE INDEX IF NOT EXISTS object_upload_sessions_expires_idx ON object_upload_sessions(expires_at);
CREATE TABLE IF NOT EXISTS remote_download_usage_reports (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
downloader_id TEXT NOT NULL,
task_id TEXT NOT NULL,
event_id TEXT NOT NULL,
unit_index INTEGER NOT NULL,
unit_bytes INTEGER NOT NULL,
credits_per_unit INTEGER NOT NULL,
status TEXT NOT NULL,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS remote_download_usage_reports_event_id_unique
ON remote_download_usage_reports(event_id);
CREATE UNIQUE INDEX IF NOT EXISTS remote_download_usage_task_unit_uniq
ON remote_download_usage_reports(task_id, unit_index);
CREATE INDEX IF NOT EXISTS remote_download_usage_org_idx ON remote_download_usage_reports(org_id);
CREATE INDEX IF NOT EXISTS remote_download_usage_status_idx ON remote_download_usage_reports(status);
CREATE TABLE IF NOT EXISTS announcements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
priority INTEGER NOT NULL DEFAULT 0,
published_at INTEGER,
expires_at INTEGER,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS announcements_status_priority_idx ON announcements(status, priority);
CREATE INDEX IF NOT EXISTS announcements_published_idx ON announcements(published_at);
CREATE TABLE IF NOT EXISTS apikey (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL DEFAULT 'default',
name TEXT,
start TEXT,
reference_id TEXT NOT NULL,
prefix TEXT,
key TEXT NOT NULL,
refill_interval INTEGER,
refill_amount INTEGER,
last_refill_at INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
rate_limit_enabled INTEGER NOT NULL DEFAULT 1,
rate_limit_time_window INTEGER,
rate_limit_max INTEGER,
request_count INTEGER NOT NULL DEFAULT 0,
remaining INTEGER,
last_request INTEGER,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
permissions TEXT,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS apikey_config_id_idx ON apikey(config_id);
CREATE INDEX IF NOT EXISTS apikey_reference_id_idx ON apikey(reference_id);
CREATE INDEX IF NOT EXISTS apikey_key_idx ON apikey(key);
CREATE TABLE IF NOT EXISTS license_bindings (
id TEXT PRIMARY KEY,
cloud_binding_id TEXT NOT NULL,
cloud_store_id TEXT,
instance_id TEXT NOT NULL,
cloud_account_id TEXT NOT NULL,
cloud_account_email TEXT,
status TEXT NOT NULL,
refresh_token TEXT,
cached_certificate TEXT,
cached_certificate_expires_at INTEGER,
bound_at INTEGER NOT NULL,
disconnected_at INTEGER,
last_refresh_at INTEGER,
last_refresh_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS license_bindings_active_uniq ON license_bindings(status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS license_bindings_cloud_binding_idx ON license_bindings(cloud_binding_id);
CREATE INDEX IF NOT EXISTS license_bindings_instance_idx ON license_bindings(instance_id);
`
export async function createTestApp(
envOverrides: Record<string, string> = {},
bindingOverrides: Record<string, unknown> = {},
) {
// Each test app is a brand-new site; drop origin state cached by a previous
// app in the same test file.
resetSitePublicOriginCache()
const sqlite = new Database(':memory:')
sqlite.exec(AUTH_SCHEMA_SQL)
sqlite.exec(APP_SCHEMA_SQL)
const db = drizzle(sqlite, { schema: { ...schema, ...authSchema } })
const platform: Platform = {
db,
getEnv: (key: string) => envOverrides[key],
getBinding: <T = unknown>(key: string) => bindingOverrides[key] as T | undefined,
}
const auth = await createAuth(platform, 'test-secret', 'http://localhost:3000')
const deps = createDeps(platform)
const app = createApp(platform, auth, deps)
return { app, db, auth, platform, deps }
}
export async function adminHeaders(app: ReturnType<typeof createApp>) {
// Sign up first user (gets promoted to admin via hook)
await authedHeaders(app, 'admin@example.com', 'password123456')
// Sign in again to get a session that reflects the admin role
const signInRes = await app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'admin@example.com', password: 'password123456' }),
})
return { Cookie: signInRes.headers.getSetCookie().join('; '), Origin: 'http://localhost:3000' }
}
export async function authedHeaders(
app: ReturnType<typeof createApp>,
email = 'test@example.com',
password = 'password123456',
) {
const signUpRes = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test User', email, password }),
})
const cookies = signUpRes.headers.getSetCookie()
// Origin matches the test app's baseURL: cookie-bearing requests to
// better-auth endpoints fail the origin check without it, like in a browser
return { Cookie: cookies.join('; '), Origin: 'http://localhost:3000' }
}
const { secretKey: TEST_LICENSE_SECRET, publicKey: TEST_LICENSE_PUBLIC } = generateKeys('public')
function nowSec(): number {
return Math.floor(Date.now() / 1000)
}
/**
* Insert a Pro license binding row so that non-commercial Pro feature gates resolve as enabled.
*/
export async function seedProLicense(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
return seedLicense(db, { edition: 'pro' })
}
/**
* Insert a Business license binding row so commercial feature gates resolve as enabled.
*/
export async function seedBusinessLicense(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
return seedLicense(db, { edition: 'business' })
}
async function seedLicense(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
input: {
edition: LicenseEdition
},
) {
const { PUBLIC_KEYS } = await import('../domain/license-keys.js')
if (!PUBLIC_KEYS.includes(TEST_LICENSE_PUBLIC)) {
PUBLIC_KEYS.unshift(TEST_LICENSE_PUBLIC)
}
const { createLicenseBindingRepo } = await import('../adapters/repos/license-binding.js')
const issuedAt = nowSec()
const expiresAt = issuedAt + 3600
const cachedCert = sign(TEST_LICENSE_SECRET, {
type: 'zpan.license',
issuer: ZPAN_CLOUD_URL_DEFAULT,
subject: 'test-binding',
accountId: 'test-account',
instanceId: 'test-instance',
storeId: 'store-test-binding',
edition: input.edition,
licenseId: 'test-license-unit',
authorizedHosts: ['localhost', 'zpan.example', 'auth.example.com', 'files.example.com'],
licenseValidUntil: issuedAt + 365 * 24 * 60 * 60,
issuedAt,
notBefore: issuedAt,
expiresAt,
})
await createLicenseBindingRepo(db).createLicenseBinding({
cloudBindingId: 'test-binding',
cloudStoreId: 'store-test-binding',
instanceId: 'test-instance',
cloudAccountId: 'test-account',
refreshToken: 'test-refresh-token',
cachedCert,
cachedExpiresAt: expiresAt,
lastRefreshAt: issuedAt,
})
}