mirror of
https://github.com/saltbo/zpan.git
synced 2026-10-31 11:12:45 +08:00
* 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>
669 lines
25 KiB
TypeScript
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,
|
|
})
|
|
}
|