From bba443817ac9de7adaa76ff38d1cc0ba8fb831e4 Mon Sep 17 00:00:00 2001 From: "agent-kanban[bot]" <295243365+agent-kanban[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:42:47 -0400 Subject: [PATCH] fix: harden legacy downloader bootstrap (#536) * fix: harden legacy downloader bootstrap Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5 * fix: cover downloader bootstrap hardening Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5 * fix: document downloader bootstrap auth policy Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5 --------- Co-authored-by: Ethan Cole --- docs/design/agent-authentication.md | 8 +- docs/roadmap/v2.9.md | 7 +- .../0080_downloader-bootstrap-credentials.sql | 17 + migrations/meta/0080_snapshot.json | 4895 +++++++++++++++++ migrations/meta/_journal.json | 7 + .../downloader-bootstrap.integration.test.ts | 158 + .../repos/downloader-bootstrap.test.ts | 238 + server/adapters/repos/downloader-bootstrap.ts | 155 + server/adapters/repos/downloader.ts | 56 +- server/auth.ts | 61 +- server/composition.ts | 5 +- server/db/auth-schema.test.ts | 46 +- server/db/auth-schema.ts | 24 + server/domain/legacy-downloader-bootstrap.ts | 12 + .../download-tasks.integration.test.ts | 116 +- server/http/downloads/downloaders.ts | 14 +- server/http/openapi.ts | 4 +- server/middleware/audit-actor.test.ts | 21 + server/middleware/audit-actor.ts | 3 + server/middleware/auth.ts | 52 + server/middleware/authz.integration.test.ts | 50 +- server/middleware/authz.ts | 2 + server/middleware/platform.ts | 16 + server/openapi.test.ts | 15 + server/test/setup.ts | 14 + server/usecases/deps.ts | 2 + server/usecases/downloads/downloads.test.ts | 107 +- server/usecases/downloads/downloads.ts | 78 +- server/usecases/ports.ts | 1 + server/usecases/ports/downloader-bootstrap.ts | 27 + spec/download-tasks.feature | 24 + 31 files changed, 6163 insertions(+), 72 deletions(-) create mode 100644 migrations/0080_downloader-bootstrap-credentials.sql create mode 100644 migrations/meta/0080_snapshot.json create mode 100644 server/adapters/repos/downloader-bootstrap.integration.test.ts create mode 100644 server/adapters/repos/downloader-bootstrap.test.ts create mode 100644 server/adapters/repos/downloader-bootstrap.ts create mode 100644 server/domain/legacy-downloader-bootstrap.ts create mode 100644 server/middleware/audit-actor.test.ts create mode 100644 server/usecases/ports/downloader-bootstrap.ts diff --git a/docs/design/agent-authentication.md b/docs/design/agent-authentication.md index 71811302..9162f588 100644 --- a/docs/design/agent-authentication.md +++ b/docs/design/agent-authentication.md @@ -21,7 +21,8 @@ revoke local tokens without a custom authorization script. Standard Agent device authorization is deferred to v2.9.x. The existing `zpan-cli` device flow remains a narrowly scoped compatibility bootstrap for downloader registration and does not manufacture an Agent API key or a general -OAuth grant. +OAuth grant. Its device-issued bearer is normalized as a single-use downloader +registration credential and is consumed after successful downloader creation. Anonymous upload and preview-and-claim are explicitly excluded. Every Agent file operation belongs to an existing user-authorized workspace from the beginning. @@ -429,8 +430,9 @@ Credentials are never recorded or redisplayed. - ZPan has bearer sessions and device authorization but is not yet an OAuth authorization server with Agent resource scopes and workspace grants. -- Device authorization validates only `zpan-cli` and currently yields a - user-oriented bearer token. +- Legacy device authorization validates only `zpan-cli` with + `downloader:register` and yields only a single-use downloader bootstrap + credential. - `shared/api-key-templates.ts` lacks an Agent template. - `server/http/objects.ts` rejects ordinary API-key principals. - authenticated shares, quota, trash, and several task routes require a user diff --git a/docs/roadmap/v2.9.md b/docs/roadmap/v2.9.md index 2f78518d..59493c1d 100644 --- a/docs/roadmap/v2.9.md +++ b/docs/roadmap/v2.9.md @@ -11,7 +11,8 @@ The authentication decision follows the current FlareAuth + Restish v2 pattern: Standard Agent device authorization is deferred to v2.9.x. The existing `zpan-cli` device flow remains only as a compatibility bootstrap for downloader -registration and does not issue a general Agent credential. +registration. Its bearer is a single-use downloader registration credential, not +a browser session or general Agent credential. OAuth grants and API keys are separate because they represent different actors: delegated user access versus a service credential. An Agent never receives a @@ -276,8 +277,8 @@ Destructive and public-sharing scopes remain separately selectable. - ZPan is not currently an OAuth authorization server for Agent resource scopes. -- The existing device authorization plugin returns a user-oriented bearer token - and validates only the legacy `zpan-cli` client ID. +- The legacy `zpan-cli` device flow is intentionally limited to the exact + `downloader:register` scope and downloader registration endpoint. - Object routes currently reject ordinary API-key principals with a blanket session-only gate. - Authenticated share and quota routes currently require a user session. diff --git a/migrations/0080_downloader-bootstrap-credentials.sql b/migrations/0080_downloader-bootstrap-credentials.sql new file mode 100644 index 00000000..da0d8ee8 --- /dev/null +++ b/migrations/0080_downloader-bootstrap-credentials.sql @@ -0,0 +1,17 @@ +CREATE TABLE `downloader_bootstrap_credentials` ( + `id` text PRIMARY KEY NOT NULL, + `token_hash` text NOT NULL, + `user_id` text NOT NULL, + `device_code` text NOT NULL, + `client_id` text NOT NULL, + `scope` text NOT NULL, + `expires_at` integer NOT NULL, + `consumed_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `downloader_bootstrap_credentials_token_hash_unique` ON `downloader_bootstrap_credentials` (`token_hash`);--> statement-breakpoint +CREATE INDEX `downloader_bootstrap_token_hash_idx` ON `downloader_bootstrap_credentials` (`token_hash`);--> statement-breakpoint +CREATE INDEX `downloader_bootstrap_user_idx` ON `downloader_bootstrap_credentials` (`user_id`);--> statement-breakpoint +CREATE INDEX `downloader_bootstrap_consumed_idx` ON `downloader_bootstrap_credentials` (`consumed_at`); \ No newline at end of file diff --git a/migrations/meta/0080_snapshot.json b/migrations/meta/0080_snapshot.json new file mode 100644 index 00000000..33e4c68e --- /dev/null +++ b/migrations/meta/0080_snapshot.json @@ -0,0 +1,4895 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a92fd738-4537-484c-8463-178ec6d07b19", + "prevId": "742380d5-c63f-470a-ba7c-568cc1a133c0", + "tables": { + "announcements": { + "name": "announcements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "announcements_status_priority_idx": { + "name": "announcements_status_priority_idx", + "columns": [ + "status", + "priority" + ], + "isUnique": false + }, + "announcements_published_idx": { + "name": "announcements_published_idx", + "columns": [ + "published_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_events": { + "name": "audit_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_name": { + "name": "target_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_ref": { + "name": "actor_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "audit_events_org_created_idx": { + "name": "audit_events_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "audit_events_user_created_idx": { + "name": "audit_events_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "audit_events_action_created_idx": { + "name": "audit_events_action_created_idx", + "columns": [ + "action", + "created_at" + ], + "isUnique": false + }, + "audit_events_target_created_idx": { + "name": "audit_events_target_created_idx", + "columns": [ + "target_type", + "target_id", + "created_at" + ], + "isUnique": false + }, + "audit_events_created_idx": { + "name": "audit_events_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "background_jobs": { + "name": "background_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_path": { + "name": "target_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_bytes": { + "name": "input_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_bytes": { + "name": "output_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "processed_bytes": { + "name": "processed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "current_filename": { + "name": "current_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_metadata": { + "name": "result_metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retryable": { + "name": "retryable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancelable": { + "name": "cancelable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "retried_from_job_id": { + "name": "retried_from_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "background_jobs_org_created_idx": { + "name": "background_jobs_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "background_jobs_org_status_idx": { + "name": "background_jobs_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "background_jobs_org_type_idx": { + "name": "background_jobs_org_type_idx", + "columns": [ + "org_id", + "type" + ], + "isUnique": false + }, + "background_jobs_created_idx": { + "name": "background_jobs_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "background_jobs_org_page_idx": { + "name": "background_jobs_org_page_idx", + "columns": [ + "org_id", + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cloud_traffic_reports": { + "name": "cloud_traffic_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "cloud_traffic_reports_event_uniq": { + "name": "cloud_traffic_reports_event_uniq", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "cloud_traffic_reports_org_period_idx": { + "name": "cloud_traffic_reports_org_period_idx", + "columns": [ + "org_id", + "period" + ], + "isUnique": false + }, + "cloud_traffic_reports_status_idx": { + "name": "cloud_traffic_reports_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "cloud_traffic_reports_retry_idx": { + "name": "cloud_traffic_reports_retry_idx", + "columns": [ + "status", + "next_retry_at", + "created_at" + ], + "isUnique": false + }, + "cloud_traffic_reports_issued_idx": { + "name": "cloud_traffic_reports_issued_idx", + "columns": [ + "issued_at" + ], + "isUnique": false + }, + "cloud_traffic_reports_updated_idx": { + "name": "cloud_traffic_reports_updated_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_tasks": { + "name": "download_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_uri": { + "name": "source_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_folder": { + "name": "target_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "assigned_downloader_id": { + "name": "assigned_downloader_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "billing_authorized_bytes": { + "name": "billing_authorized_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_charged_bytes": { + "name": "billing_charged_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_charged_credits": { + "name": "billing_charged_credits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "billing_status": { + "name": "billing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_object_id": { + "name": "result_object_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runtime": { + "name": "runtime", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "resolve_started_at": { + "name": "resolve_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolve_completed_at": { + "name": "resolve_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_completed_at": { + "name": "download_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ingest_started_at": { + "name": "ingest_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ingest_completed_at": { + "name": "ingest_completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seeding_started_at": { + "name": "seeding_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seeding_stopped_at": { + "name": "seeding_stopped_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "download_tasks_org_created_idx": { + "name": "download_tasks_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "download_tasks_org_status_idx": { + "name": "download_tasks_org_status_idx", + "columns": [ + "org_id", + "status" + ], + "isUnique": false + }, + "download_tasks_org_category_idx": { + "name": "download_tasks_org_category_idx", + "columns": [ + "org_id", + "category" + ], + "isUnique": false + }, + "download_tasks_org_tags_idx": { + "name": "download_tasks_org_tags_idx", + "columns": [ + "org_id", + "tags" + ], + "isUnique": false + }, + "download_tasks_downloader_idx": { + "name": "download_tasks_downloader_idx", + "columns": [ + "assigned_downloader_id", + "status" + ], + "isUnique": false + }, + "download_tasks_created_idx": { + "name": "download_tasks_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "download_tasks_finished_idx": { + "name": "download_tasks_finished_idx", + "columns": [ + "finished_at" + ], + "isUnique": false + }, + "download_tasks_org_deleted_created_idx": { + "name": "download_tasks_org_deleted_created_idx", + "columns": [ + "org_id", + "deleted_at", + "created_at" + ], + "isUnique": false + }, + "download_tasks_org_page_idx": { + "name": "download_tasks_org_page_idx", + "columns": [ + "org_id", + "deleted_at", + "created_at", + "id" + ], + "isUnique": false + }, + "download_tasks_downloader_page_idx": { + "name": "download_tasks_downloader_page_idx", + "columns": [ + "assigned_downloader_id", + "deleted_at", + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloaders": { + "name": "downloaders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_jti": { + "name": "token_jti", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'offline'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "arch": { + "name": "arch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "engine": { + "name": "engine", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "max_concurrent_tasks": { + "name": "max_concurrent_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "current_tasks": { + "name": "current_tasks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "download_bps": { + "name": "download_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upload_bps": { + "name": "upload_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "free_disk_bytes": { + "name": "free_disk_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remote_download_credit_billing_enabled": { + "name": "remote_download_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remote_download_credit_unit_bytes": { + "name": "remote_download_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "remote_download_credit_per_unit": { + "name": "remote_download_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloaders_token_jti_unique": { + "name": "downloaders_token_jti_unique", + "columns": [ + "token_jti" + ], + "isUnique": true + }, + "downloaders_status_idx": { + "name": "downloaders_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "downloaders_enabled_idx": { + "name": "downloaders_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downloaders_created_idx": { + "name": "downloaders_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hosting_configs": { + "name": "image_hosting_configs", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_provider": { + "name": "domain_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_hostname_id": { + "name": "provider_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_status": { + "name": "domain_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_error": { + "name": "domain_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_last_checked_at": { + "name": "domain_last_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_verified_at": { + "name": "domain_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referer_allowlist": { + "name": "referer_allowlist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hosting_configs_custom_domain_unique": { + "name": "image_hosting_configs_custom_domain_unique", + "columns": [ + "custom_domain" + ], + "isUnique": true + } + }, + "foreignKeys": { + "image_hosting_configs_org_id_organization_id_fk": { + "name": "image_hosting_configs_org_id_organization_id_fk", + "tableFrom": "image_hosting_configs", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "image_hostings": { + "name": "image_hostings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "image_hostings_token_unique": { + "name": "image_hostings_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "image_hostings_org_path_uniq": { + "name": "image_hostings_org_path_uniq", + "columns": [ + "org_id", + "path" + ], + "isUnique": true, + "where": "\"image_hostings\".\"purged_at\" IS NULL" + }, + "image_hostings_org_created_idx": { + "name": "image_hostings_org_created_idx", + "columns": [ + "org_id", + "created_at" + ], + "isUnique": false + }, + "image_hostings_page_idx": { + "name": "image_hostings_page_idx", + "columns": [ + "org_id", + "status", + "purged_at", + "created_at", + "id" + ], + "isUnique": false + }, + "image_hostings_token_idx": { + "name": "image_hostings_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "image_hostings_org_id_organization_id_fk": { + "name": "image_hostings_org_id_organization_id_fk", + "tableFrom": "image_hostings", + "tableTo": "organization", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_hostings_storage_id_storages_id_fk": { + "name": "image_hostings_storage_id_storages_id_fk", + "tableFrom": "image_hostings", + "tableTo": "storages", + "columnsFrom": [ + "storage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite_codes": { + "name": "invite_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_by": { + "name": "used_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_codes_code_unique": { + "name": "invite_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "license_bindings": { + "name": "license_bindings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cloud_binding_id": { + "name": "cloud_binding_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_store_id": { + "name": "cloud_store_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_id": { + "name": "cloud_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_account_email": { + "name": "cloud_account_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate": { + "name": "cached_certificate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cached_certificate_expires_at": { + "name": "cached_certificate_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bound_at": { + "name": "bound_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_at": { + "name": "last_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_error": { + "name": "last_refresh_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "license_bindings_active_uniq": { + "name": "license_bindings_active_uniq", + "columns": [ + "status" + ], + "isUnique": true, + "where": "status = 'active'" + }, + "license_bindings_cloud_binding_idx": { + "name": "license_bindings_cloud_binding_idx", + "columns": [ + "cloud_binding_id" + ], + "isUnique": false + }, + "license_bindings_instance_idx": { + "name": "license_bindings_instance_idx", + "columns": [ + "instance_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "matters": { + "name": "matters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "dirtype": { + "name": "dirtype", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "parent": { + "name": "parent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "trashed_at": { + "name": "trashed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "matters_alias_unique": { + "name": "matters_alias_unique", + "columns": [ + "alias" + ], + "isUnique": true + }, + "matters_status_dir_created_idx": { + "name": "matters_status_dir_created_idx", + "columns": [ + "status", + "dirtype", + "created_at" + ], + "isUnique": false + }, + "matters_webdav_path_idx": { + "name": "matters_webdav_path_idx", + "columns": [ + "org_id", + "parent", + "name", + "status", + "trashed_at", + "purged_at" + ], + "isUnique": false + }, + "matters_webdav_children_idx": { + "name": "matters_webdav_children_idx", + "columns": [ + "org_id", + "parent", + "status", + "trashed_at", + "purged_at", + "\"dirtype\" desc", + "name" + ], + "isUnique": false + }, + "matters_list_page_idx": { + "name": "matters_list_page_idx", + "columns": [ + "org_id", + "parent", + "status", + "trashed_at", + "purged_at", + "\"dirtype\" desc", + "created_at", + "id" + ], + "isUnique": false + }, + "matters_trash_page_idx": { + "name": "matters_trash_page_idx", + "columns": [ + "org_id", + "status", + "purged_at", + "trashed_at", + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + }, + "notifications_user_read_idx": { + "name": "notifications_user_read_idx", + "columns": [ + "user_id", + "read_at" + ], + "isUnique": false + }, + "notifications_user_page_idx": { + "name": "notifications_user_page_idx", + "columns": [ + "user_id", + "read_at", + "created_at", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "object_upload_sessions": { + "name": "object_upload_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_id": { + "name": "object_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upload_id": { + "name": "upload_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "on_conflict": { + "name": "on_conflict", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fail'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "object_upload_sessions_object_idx": { + "name": "object_upload_sessions_object_idx", + "columns": [ + "org_id", + "object_id" + ], + "isUnique": false + }, + "object_upload_sessions_expires_idx": { + "name": "object_upload_sessions_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quota_entitlements": { + "name": "org_quota_entitlements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entitlement_type": { + "name": "entitlement_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'grant'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "starts_at": { + "name": "starts_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "org_quota_entitlements_org_resource_idx": { + "name": "org_quota_entitlements_org_resource_idx", + "columns": [ + "org_id", + "resource_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_org_type_idx": { + "name": "org_quota_entitlements_org_type_idx", + "columns": [ + "org_id", + "resource_type", + "entitlement_type", + "status" + ], + "isUnique": false + }, + "org_quota_entitlements_active_plan_uniq": { + "name": "org_quota_entitlements_active_plan_uniq", + "columns": [ + "org_id", + "resource_type", + "entitlement_type" + ], + "isUnique": true, + "where": "status = 'active' AND entitlement_type = 'plan' AND source <> 'free_plan'" + }, + "org_quota_entitlements_source_resource_uniq": { + "name": "org_quota_entitlements_source_resource_uniq", + "columns": [ + "source", + "source_id", + "resource_type" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "org_quotas": { + "name": "org_quotas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quota": { + "name": "quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_quota": { + "name": "traffic_quota", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_used": { + "name": "traffic_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "traffic_period": { + "name": "traffic_period", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01'" + } + }, + "indexes": { + "org_quotas_org_uniq": { + "name": "org_quotas_org_uniq", + "columns": [ + "org_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "remote_download_usage_reports": { + "name": "remote_download_usage_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloader_id": { + "name": "downloader_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_index": { + "name": "unit_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unit_bytes": { + "name": "unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credits_per_unit": { + "name": "credits_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "remote_download_usage_reports_event_id_unique": { + "name": "remote_download_usage_reports_event_id_unique", + "columns": [ + "event_id" + ], + "isUnique": true + }, + "remote_download_usage_task_unit_uniq": { + "name": "remote_download_usage_task_unit_uniq", + "columns": [ + "task_id", + "unit_index" + ], + "isUnique": true + }, + "remote_download_usage_org_idx": { + "name": "remote_download_usage_org_idx", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "remote_download_usage_status_idx": { + "name": "remote_download_usage_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "remote_download_usage_created_idx": { + "name": "remote_download_usage_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "resource_changes": { + "name": "resource_changes", + "columns": { + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "resource_changes_scope_sequence_idx": { + "name": "resource_changes_scope_sequence_idx", + "columns": [ + "scope_type", + "scope_id", + "sequence" + ], + "isUnique": false + }, + "resource_changes_resource_sequence_idx": { + "name": "resource_changes_resource_sequence_idx", + "columns": [ + "resource_type", + "resource_id", + "sequence" + ], + "isUnique": false + }, + "resource_changes_occurred_idx": { + "name": "resource_changes_occurred_idx", + "columns": [ + "occurred_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "share_recipients": { + "name": "share_recipients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "share_id": { + "name": "share_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "share_recipients_share_id_idx": { + "name": "share_recipients_share_id_idx", + "columns": [ + "share_id" + ], + "isUnique": false + }, + "share_recipients_user_id_idx": { + "name": "share_recipients_user_id_idx", + "columns": [ + "recipient_user_id" + ], + "isUnique": false + }, + "share_recipients_email_idx": { + "name": "share_recipients_email_idx", + "columns": [ + "recipient_email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "shares": { + "name": "shares", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matter_id": { + "name": "matter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_limit": { + "name": "download_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "downloads": { + "name": "downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "private": { + "name": "private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "shares_token_unique": { + "name": "shares_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "shares_creator_status_created_idx": { + "name": "shares_creator_status_created_idx", + "columns": [ + "creator_id", + "status", + "created_at", + "id" + ], + "isUnique": false + }, + "shares_creator_private_created_idx": { + "name": "shares_creator_private_created_idx", + "columns": [ + "creator_id", + "private", + "created_at" + ], + "isUnique": false + }, + "shares_created_idx": { + "name": "shares_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_invitations": { + "name": "site_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "site_invitations_token_unique": { + "name": "site_invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "site_invitations_email_idx": { + "name": "site_invitations_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "site_invitations_created_idx": { + "name": "site_invitations_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "site_invitations_expires_idx": { + "name": "site_invitations_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stats_rollups_hourly": { + "name": "stats_rollups_hourly", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "metric_key": { + "name": "metric_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dimension_key": { + "name": "dimension_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dimension_value": { + "name": "dimension_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unique_count": { + "name": "unique_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "stats_rollups_hourly_bucket_metric_dim_uniq": { + "name": "stats_rollups_hourly_bucket_metric_dim_uniq", + "columns": [ + "bucket_start", + "org_id", + "metric_key", + "dimension_key", + "dimension_value" + ], + "isUnique": true + }, + "stats_rollups_hourly_metric_bucket_idx": { + "name": "stats_rollups_hourly_metric_bucket_idx", + "columns": [ + "metric_key", + "bucket_start" + ], + "isUnique": false + }, + "stats_rollups_hourly_dimension_bucket_idx": { + "name": "stats_rollups_hourly_dimension_bucket_idx", + "columns": [ + "metric_key", + "dimension_key", + "bucket_start" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_usage_breakdowns": { + "name": "storage_usage_breakdowns", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "file_count": { + "name": "file_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "storage_usage_breakdowns_org_category_uniq": { + "name": "storage_usage_breakdowns_org_category_uniq", + "columns": [ + "org_id", + "category" + ], + "isUnique": true + }, + "storage_usage_breakdowns_org_idx": { + "name": "storage_usage_breakdowns_org_idx", + "columns": [ + "org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storage_usage_ledger": { + "name": "storage_usage_ledger", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_id": { + "name": "storage_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delta_bytes": { + "name": "delta_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "storage_usage_ledger_event_key_unique": { + "name": "storage_usage_ledger_event_key_unique", + "columns": [ + "event_key" + ], + "isUnique": true + }, + "storage_usage_ledger_occurred_idx": { + "name": "storage_usage_ledger_occurred_idx", + "columns": [ + "occurred_at" + ], + "isUnique": false + }, + "storage_usage_ledger_org_occurred_idx": { + "name": "storage_usage_ledger_org_occurred_idx", + "columns": [ + "org_id", + "occurred_at" + ], + "isUnique": false + }, + "storage_usage_ledger_storage_occurred_idx": { + "name": "storage_usage_ledger_storage_occurred_idx", + "columns": [ + "storage_id", + "occurred_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "storages": { + "name": "storages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'auto'" + }, + "access_key": { + "name": "access_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_key": { + "name": "secret_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "custom_host": { + "name": "custom_host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "egress_credit_billing_enabled": { + "name": "egress_credit_billing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "egress_credit_unit_bytes": { + "name": "egress_credit_unit_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 104857600 + }, + "egress_credit_per_unit": { + "name": "egress_credit_per_unit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "force_path_style": { + "name": "force_path_style", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_checked_at": { + "name": "status_checked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "system_options": { + "name": "system_options", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_invite_links": { + "name": "team_invite_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "team_invite_links_token_unique": { + "name": "team_invite_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_dead_properties": { + "name": "webdav_dead_properties", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_dead_properties_resource_prop_uniq": { + "name": "webdav_dead_properties_resource_prop_uniq", + "columns": [ + "org_id", + "resource_path", + "namespace", + "name" + ], + "isUnique": true + }, + "webdav_dead_properties_resource_idx": { + "name": "webdav_dead_properties_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webdav_locks": { + "name": "webdav_locks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_path": { + "name": "resource_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "depth": { + "name": "depth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'infinity'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webdav_locks_token_unique": { + "name": "webdav_locks_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "webdav_locks_resource_idx": { + "name": "webdav_locks_resource_idx", + "columns": [ + "org_id", + "resource_path" + ], + "isUnique": false + }, + "webdav_locks_expires_idx": { + "name": "webdav_locks_expires_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_events": { + "name": "webhook_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cloud'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'order.quota_changed'" + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhook_events_source_event_uniq": { + "name": "webhook_events_source_event_uniq", + "columns": [ + "source", + "event_id" + ], + "isUnique": true + }, + "webhook_events_source_created_idx": { + "name": "webhook_events_source_created_idx", + "columns": [ + "source", + "created_at" + ], + "isUnique": false + }, + "webhook_events_status_idx": { + "name": "webhook_events_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "webhook_events_processed_idx": { + "name": "webhook_events_processed_idx", + "columns": [ + "processed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apikey": { + "name": "apikey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deviceCode": { + "name": "deviceCode", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "deviceCode_device_code_idx": { + "name": "deviceCode_device_code_idx", + "columns": [ + "device_code" + ], + "isUnique": false + }, + "deviceCode_user_code_idx": { + "name": "deviceCode_user_code_idx", + "columns": [ + "user_code" + ], + "isUnique": false + }, + "deviceCode_status_idx": { + "name": "deviceCode_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloader_bootstrap_credentials": { + "name": "downloader_bootstrap_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "downloader_bootstrap_credentials_token_hash_unique": { + "name": "downloader_bootstrap_credentials_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "downloader_bootstrap_token_hash_idx": { + "name": "downloader_bootstrap_token_hash_idx", + "columns": [ + "token_hash" + ], + "isUnique": false + }, + "downloader_bootstrap_user_idx": { + "name": "downloader_bootstrap_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "downloader_bootstrap_consumed_idx": { + "name": "downloader_bootstrap_consumed_idx", + "columns": [ + "consumed_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "downloader_bootstrap_credentials_user_id_user_id_fk": { + "name": "downloader_bootstrap_credentials_user_id_user_id_fk", + "tableFrom": "downloader_bootstrap_credentials", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "session_created_idx": { + "name": "session_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banned": { + "name": "banned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "user_created_idx": { + "name": "user_created_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "user_lastActiveAt_idx": { + "name": "user_lastActiveAt_idx", + "columns": [ + "last_active_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "matters_webdav_children_idx": { + "columns": { + "\"dirtype\" desc": { + "isExpression": true + } + } + }, + "matters_list_page_idx": { + "columns": { + "\"dirtype\" desc": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 086f8392..a9086b2b 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -554,6 +554,13 @@ "when": 1785201664275, "tag": "0079_image-domain-providers", "breakpoints": true + }, + { + "idx": 80, + "version": "6", + "when": 1785289409896, + "tag": "0080_downloader-bootstrap-credentials", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/adapters/repos/downloader-bootstrap.integration.test.ts b/server/adapters/repos/downloader-bootstrap.integration.test.ts new file mode 100644 index 00000000..97c10de0 --- /dev/null +++ b/server/adapters/repos/downloader-bootstrap.integration.test.ts @@ -0,0 +1,158 @@ +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import { downloaderBootstrapCredential, session, user } from '../../db/auth-schema' +import { downloaders } from '../../db/schema' +import { createTestApp } from '../../test/setup' +import type { CreateDownloaderRecordInput } from '../../usecases/ports' +import { createDownloaderBootstrapCredentialRepo } from './downloader-bootstrap' + +const now = new Date('2026-07-29T00:00:00.000Z') + +describe('downloader bootstrap credential repo', () => { + it('resolves active, expired, and consumed bootstrap credentials', async () => { + const { db, platform } = await createTestApp() + await seedUser(db, 'user-1') + const repo = createDownloaderBootstrapCredentialRepo(db, hashOnlyTokens()) + + await repo.issue({ + platform, + token: 'active-token', + userId: 'user-1', + deviceCode: 'device-active', + expiresAt: new Date(now.getTime() + 60_000), + }) + await repo.issue({ + platform, + token: 'expired-token', + userId: 'user-1', + deviceCode: 'device-expired', + expiresAt: new Date(now.getTime() - 1), + }) + + await expect(repo.resolve(platform, 'missing-token', now)).resolves.toBeNull() + await expect(repo.resolve(platform, 'active-token', now)).resolves.toEqual({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + active: true, + }) + await expect(repo.resolve(platform, 'expired-token', now)).resolves.toEqual({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + active: false, + }) + + await db + .update(downloaderBootstrapCredential) + .set({ consumedAt: now }) + .where(eq(downloaderBootstrapCredential.tokenHash, 'hash:active-token')) + await expect(repo.resolve(platform, 'active-token', now)).resolves.toMatchObject({ active: false }) + }) + + it('consumes a bootstrap credential once', async () => { + const { db, platform } = await createTestApp() + await seedUser(db, 'user-1') + const repo = createDownloaderBootstrapCredentialRepo(db, hashOnlyTokens()) + + await repo.issue({ + platform, + token: 'consume-token', + userId: 'user-1', + deviceCode: 'device-consume', + expiresAt: new Date(now.getTime() + 60_000), + }) + + await expect(repo.consume(platform, 'consume-token', now)).resolves.toEqual({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + active: false, + }) + await expect(repo.consume(platform, 'consume-token', now)).resolves.toBeNull() + await expect(repo.consume(platform, 'missing-token', now)).resolves.toBeNull() + }) + + it('atomically consumes bootstrap credentials while registering downloaders', async () => { + const { db, platform } = await createTestApp() + await seedUser(db, 'user-1') + const repo = createDownloaderBootstrapCredentialRepo(db, hashOnlyTokens()) + + await repo.issue({ + platform, + token: 'register-token', + userId: 'user-1', + deviceCode: 'device-register', + expiresAt: new Date(now.getTime() + 60_000), + }) + await db.insert(session).values({ + id: 'session-1', + token: 'register-token', + userId: 'user-1', + expiresAt: new Date(now.getTime() + 60_000), + createdAt: now, + updatedAt: now, + }) + + await expect( + repo.registerDownloader({ + platform, + token: 'register-token', + now, + downloader: downloaderRecord('downloader-1', 'user-1'), + }), + ).resolves.toBe(true) + await expect( + repo.registerDownloader({ + platform, + token: 'register-token', + now, + downloader: downloaderRecord('downloader-2', 'user-1'), + }), + ).resolves.toBe(false) + + await expect(db.select().from(downloaders).where(eq(downloaders.id, 'downloader-1'))).resolves.toHaveLength(1) + await expect(db.select().from(downloaders).where(eq(downloaders.id, 'downloader-2'))).resolves.toHaveLength(0) + await expect(db.select().from(session).where(eq(session.token, 'register-token'))).resolves.toHaveLength(0) + }) +}) + +function hashOnlyTokens() { + return { + hashDownloadToken: async (_platform: unknown, token: string) => `hash:${token}`, + } +} + +async function seedUser(db: Awaited>['db'], id: string) { + await db.insert(user).values({ + id, + name: 'Bootstrap User', + email: `${id}@example.com`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) +} + +function downloaderRecord(id: string, createdBy: string): CreateDownloaderRecordInput { + return { + id, + name: 'Edge worker', + tokenHash: `hash:${id}`, + tokenJti: `jti:${id}`, + version: '1.0.0', + hostname: 'edge-1', + platform: 'linux', + arch: 'amd64', + engine: 'aria2', + capabilities: ['http'], + maxConcurrentTasks: 2, + currentTasks: 0, + downloadBps: 0, + uploadBps: 0, + freeDiskBytes: 1024, + remoteDownloadCreditUnitBytes: 100 * 1024 * 1024, + createdBy, + now, + } +} diff --git a/server/adapters/repos/downloader-bootstrap.test.ts b/server/adapters/repos/downloader-bootstrap.test.ts new file mode 100644 index 00000000..c0a72388 --- /dev/null +++ b/server/adapters/repos/downloader-bootstrap.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../db/transaction', () => ({ + executeRows: vi.fn(), + executeWriteTransactionWithResults: vi.fn(), +})) + +import { executeRows, executeWriteTransactionWithResults } from '../../db/transaction' +import type { Platform } from '../../platform/interface' +import { createDownloaderBootstrapCredentialRepo } from './downloader-bootstrap' + +const platform: Platform = { + db: {} as never, + getEnv: () => undefined, + getBinding: () => undefined, +} + +function createDb(selectRow?: Record | null, consumeRows: Array<{ userId: string }> = []) { + const selectLimit = vi.fn(async () => (selectRow ? [selectRow] : [])) + const selectWhere = vi.fn(() => ({ limit: selectLimit })) + const selectFrom = vi.fn(() => ({ where: selectWhere })) + const select = vi.fn(() => ({ from: selectFrom })) + + const updateAll = vi.fn(() => consumeRows) + const updateReturning = vi.fn(() => ({ all: updateAll })) + const updateWhere = vi.fn(() => ({ returning: updateReturning, all: updateAll })) + const updateSet = vi.fn(() => ({ where: updateWhere })) + const update = vi.fn(() => ({ set: updateSet })) + + const insertSelect = vi.fn(() => ({ run: vi.fn() })) + const insertValues = vi.fn(() => ({ run: vi.fn() })) + const insert = vi.fn(() => ({ values: insertValues, select: insertSelect })) + const deleteWhere = vi.fn(() => ({ run: vi.fn() })) + const deleteFn = vi.fn(() => ({ where: deleteWhere })) + + return { + db: { + select, + update, + insert, + delete: deleteFn, + } as never, + selectLimit, + updateAll, + insertSelect, + insertValues, + deleteWhere, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(executeRows).mockImplementation(async (query) => ('all' in query ? query.all() : [])) +}) + +describe('createDownloaderBootstrapCredentialRepo', () => { + it('stores a hashed bootstrap credential for later registration', async () => { + const { db, insertValues } = createDb() + const hashDownloadToken = vi.fn(async () => 'hashed-token') + const repo = createDownloaderBootstrapCredentialRepo(db, { hashDownloadToken }) + + await repo.issue({ + platform, + token: 'bootstrap-token', + userId: 'user-1', + deviceCode: 'device-code-1', + expiresAt: new Date('2026-07-29T13:00:00.000Z'), + }) + + expect(hashDownloadToken).toHaveBeenCalledWith(platform, 'bootstrap-token') + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + tokenHash: 'hashed-token', + userId: 'user-1', + deviceCode: 'device-code-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + expiresAt: new Date('2026-07-29T13:00:00.000Z'), + createdAt: expect.any(Date), + }), + ) + }) + + it('returns null when resolve does not find a matching credential', async () => { + const { db } = createDb(null) + const repo = createDownloaderBootstrapCredentialRepo(db, { + hashDownloadToken: vi.fn(async () => 'hashed-token'), + }) + + await expect(repo.resolve(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toBeNull() + }) + + it('marks consumed credentials as inactive when resolved', async () => { + const { db } = createDb({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + expiresAt: new Date('2026-07-29T13:00:00.000Z'), + consumedAt: new Date('2026-07-29T12:30:00.000Z'), + }) + const repo = createDownloaderBootstrapCredentialRepo(db, { + hashDownloadToken: vi.fn(async () => 'hashed-token'), + }) + + await expect(repo.resolve(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toEqual({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + active: false, + }) + }) + + it('marks expired credentials as inactive when resolved', async () => { + const { db } = createDb({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + expiresAt: new Date('2026-07-29T11:59:59.000Z'), + consumedAt: null, + }) + const repo = createDownloaderBootstrapCredentialRepo(db, { + hashDownloadToken: vi.fn(async () => 'hashed-token'), + }) + + await expect(repo.resolve(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toEqual({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + active: false, + }) + }) + + it('returns null when consume cannot update an active credential', async () => { + const { db } = createDb(null, []) + const repo = createDownloaderBootstrapCredentialRepo(db, { + hashDownloadToken: vi.fn(async () => 'hashed-token'), + }) + + await expect(repo.consume(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toBeNull() + }) + + it('returns the consumed credential metadata when consume succeeds', async () => { + const { db } = createDb(null, [{ userId: 'user-1' }]) + const repo = createDownloaderBootstrapCredentialRepo(db, { + hashDownloadToken: vi.fn(async () => 'hashed-token'), + }) + + await expect(repo.consume(platform, 'bootstrap-token', new Date('2026-07-29T12:00:00.000Z'))).resolves.toEqual({ + userId: 'user-1', + clientId: 'zpan-cli', + scope: 'downloader:register', + active: false, + }) + }) + + it('returns true when downloader registration consumes exactly one credential', async () => { + const { db, insertSelect, deleteWhere } = createDb(null, [{ userId: 'user-1' }]) + vi.mocked(executeWriteTransactionWithResults).mockImplementation(async (_db, queries) => [ + undefined, + queries[1].all?.(), + undefined, + ]) + const repo = createDownloaderBootstrapCredentialRepo(db, { + hashDownloadToken: vi.fn(async () => 'hashed-token'), + }) + + await expect( + repo.registerDownloader({ + platform, + token: 'bootstrap-token', + now: new Date('2026-07-29T12:00:00.000Z'), + downloader: { + id: 'downloader-1', + name: 'Bootstrap downloader', + tokenHash: 'downloader-hash', + tokenJti: 'token-jti', + version: '1.2.3', + hostname: 'bootstrap-edge', + platform: 'linux', + arch: 'amd64', + engine: 'aria2', + capabilities: ['http'], + maxConcurrentTasks: 2, + currentTasks: 0, + downloadBps: 0, + uploadBps: 0, + freeDiskBytes: 4096, + remoteDownloadCreditUnitBytes: 104857600, + createdBy: 'user-1', + now: new Date('2026-07-29T12:00:00.000Z'), + }, + }), + ).resolves.toBe(true) + + expect(executeWriteTransactionWithResults).toHaveBeenCalledWith(db, expect.any(Array), [1]) + expect(insertSelect).toHaveBeenCalled() + expect(deleteWhere).toHaveBeenCalled() + }) + + it('returns false when downloader registration does not consume a credential', async () => { + const { db } = createDb() + vi.mocked(executeWriteTransactionWithResults).mockImplementation(async (_db, queries) => { + const consumed = queries[1].all?.() + return [undefined, Array.isArray(consumed) ? [] : consumed, undefined] + }) + const repo = createDownloaderBootstrapCredentialRepo(db, { + hashDownloadToken: vi.fn(async () => 'hashed-token'), + }) + + await expect( + repo.registerDownloader({ + platform, + token: 'bootstrap-token', + now: new Date('2026-07-29T12:00:00.000Z'), + downloader: { + id: 'downloader-1', + name: 'Bootstrap downloader', + tokenHash: 'downloader-hash', + tokenJti: 'token-jti', + version: '1.2.3', + hostname: 'bootstrap-edge', + platform: 'linux', + arch: 'amd64', + engine: 'aria2', + capabilities: ['http'], + maxConcurrentTasks: 2, + currentTasks: 0, + downloadBps: 0, + uploadBps: 0, + freeDiskBytes: 4096, + remoteDownloadCreditUnitBytes: 104857600, + createdBy: 'user-1', + now: new Date('2026-07-29T12:00:00.000Z'), + }, + }), + ).resolves.toBe(false) + }) +}) diff --git a/server/adapters/repos/downloader-bootstrap.ts b/server/adapters/repos/downloader-bootstrap.ts new file mode 100644 index 00000000..da84589f --- /dev/null +++ b/server/adapters/repos/downloader-bootstrap.ts @@ -0,0 +1,155 @@ +import { and, eq, gt, isNull, sql } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { downloaderBootstrapCredential, session } from '../../db/auth-schema' +import { downloaders } from '../../db/schema' +import { executeRows, executeWriteTransactionWithResults } from '../../db/transaction' +import { LEGACY_DOWNLOADER_CLIENT_ID, LEGACY_DOWNLOADER_REGISTER_SCOPE } from '../../domain/legacy-downloader-bootstrap' +import type { Database } from '../../platform/interface' +import type { DownloaderBootstrapCredentialRepo, DownloadTokenGateway } from '../../usecases/ports' +import { downloaderInsertValues } from './downloader' + +export function createDownloaderBootstrapCredentialRepo( + db: Database, + tokens: Pick, +): DownloaderBootstrapCredentialRepo { + return { + async issue(input) { + await db.insert(downloaderBootstrapCredential).values({ + id: nanoid(), + tokenHash: await tokens.hashDownloadToken(input.platform, input.token), + userId: input.userId, + deviceCode: input.deviceCode, + clientId: LEGACY_DOWNLOADER_CLIENT_ID, + scope: LEGACY_DOWNLOADER_REGISTER_SCOPE, + expiresAt: input.expiresAt, + createdAt: new Date(), + }) + }, + + async resolve(platform, token, now) { + const tokenHash = await tokens.hashDownloadToken(platform, token) + const [row] = await db + .select({ + userId: downloaderBootstrapCredential.userId, + clientId: downloaderBootstrapCredential.clientId, + scope: downloaderBootstrapCredential.scope, + expiresAt: downloaderBootstrapCredential.expiresAt, + consumedAt: downloaderBootstrapCredential.consumedAt, + }) + .from(downloaderBootstrapCredential) + .where( + and( + eq(downloaderBootstrapCredential.tokenHash, tokenHash), + eq(downloaderBootstrapCredential.clientId, LEGACY_DOWNLOADER_CLIENT_ID), + eq(downloaderBootstrapCredential.scope, LEGACY_DOWNLOADER_REGISTER_SCOPE), + ), + ) + .limit(1) + if (!row) return null + return { + userId: row.userId, + clientId: LEGACY_DOWNLOADER_CLIENT_ID, + scope: LEGACY_DOWNLOADER_REGISTER_SCOPE, + active: row.consumedAt === null && row.expiresAt > now, + } + }, + + async consume(platform, token, now) { + const tokenHash = await tokens.hashDownloadToken(platform, token) + const [row] = await executeRows<{ userId: string }>({ + all: () => + consumeBootstrapQuery(db, tokenHash, now) + .returning({ + userId: downloaderBootstrapCredential.userId, + }) + .all(), + }) + if (!row) return null + return { + userId: row.userId, + clientId: LEGACY_DOWNLOADER_CLIENT_ID, + scope: LEGACY_DOWNLOADER_REGISTER_SCOPE, + active: false, + } + }, + + async registerDownloader(input) { + const tokenHash = await tokens.hashDownloadToken(input.platform, input.token) + const consumeBootstrap = { + all: () => + consumeBootstrapQuery(db, tokenHash, input.now) + .returning({ + userId: downloaderBootstrapCredential.userId, + }) + .all(), + } + const insertDownloader = conditionalDownloaderInsertQuery(db, input.downloader, tokenHash) + const deleteBootstrapSession = db.delete(session).where(eq(session.token, input.token)) + const [, consumeResult] = await executeWriteTransactionWithResults( + db, + [insertDownloader, consumeBootstrap, deleteBootstrapSession], + [1], + ) + return Array.isArray(consumeResult) && consumeResult.length === 1 + }, + } +} + +function consumeBootstrapQuery(db: Database, tokenHash: string, now: Date) { + return db + .update(downloaderBootstrapCredential) + .set({ consumedAt: now }) + .where( + and( + eq(downloaderBootstrapCredential.tokenHash, tokenHash), + eq(downloaderBootstrapCredential.clientId, LEGACY_DOWNLOADER_CLIENT_ID), + eq(downloaderBootstrapCredential.scope, LEGACY_DOWNLOADER_REGISTER_SCOPE), + isNull(downloaderBootstrapCredential.consumedAt), + gt(downloaderBootstrapCredential.expiresAt, now), + ), + ) +} + +function conditionalDownloaderInsertQuery( + db: Database, + input: Parameters[0], + tokenHash: string, +) { + const values = downloaderInsertValues(input) + return db.insert(downloaders).select(sql` + SELECT + ${values.id}, + ${values.name}, + ${values.tokenHash}, + ${values.tokenJti}, + ${values.status}, + ${values.enabled ? 1 : 0}, + ${values.version}, + ${values.hostname}, + ${values.platform}, + ${values.arch}, + ${values.engine}, + ${values.capabilities}, + ${values.maxConcurrentTasks}, + ${values.currentTasks}, + ${values.downloadBps}, + ${values.uploadBps}, + ${values.freeDiskBytes}, + ${values.remoteDownloadCreditBillingEnabled ? 1 : 0}, + ${values.remoteDownloadCreditUnitBytes}, + ${values.remoteDownloadCreditPerUnit}, + ${values.lastHeartbeatAt}, + ${values.createdBy}, + ${values.createdAt.getTime()}, + ${values.updatedAt.getTime()} + WHERE EXISTS ( + SELECT 1 + FROM ${downloaderBootstrapCredential} + WHERE ${downloaderBootstrapCredential.tokenHash} = ${tokenHash} + AND ${downloaderBootstrapCredential.clientId} = ${LEGACY_DOWNLOADER_CLIENT_ID} + AND ${downloaderBootstrapCredential.scope} = ${LEGACY_DOWNLOADER_REGISTER_SCOPE} + AND ${downloaderBootstrapCredential.consumedAt} IS NULL + AND ${downloaderBootstrapCredential.expiresAt} > ${input.now.getTime()} + ) + `) +} diff --git a/server/adapters/repos/downloader.ts b/server/adapters/repos/downloader.ts index 4bf3f1fa..bd9123ad 100644 --- a/server/adapters/repos/downloader.ts +++ b/server/adapters/repos/downloader.ts @@ -80,6 +80,35 @@ function toDownloader(row: DownloaderRow): Downloader { const DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT = 1 +export function downloaderInsertValues(input: CreateDownloaderRecordInput) { + return { + id: input.id, + name: input.name, + tokenHash: input.tokenHash, + tokenJti: input.tokenJti, + status: 'offline', + enabled: true, + version: input.version, + hostname: input.hostname, + platform: input.platform, + arch: input.arch, + engine: input.engine, + capabilities: JSON.stringify(input.capabilities), + maxConcurrentTasks: input.maxConcurrentTasks, + currentTasks: input.currentTasks, + downloadBps: input.downloadBps, + uploadBps: input.uploadBps, + freeDiskBytes: input.freeDiskBytes, + remoteDownloadCreditBillingEnabled: false, + remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes, + remoteDownloadCreditPerUnit: DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT, + lastHeartbeatAt: null, + createdBy: input.createdBy, + createdAt: input.now, + updatedAt: input.now, + } +} + export function createDownloaderRepo(db: Database): DownloaderRepo { async function findRow(id: string): Promise { const rows = await db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1) @@ -88,32 +117,7 @@ export function createDownloaderRepo(db: Database): DownloaderRepo { return { async insert(input: CreateDownloaderRecordInput) { - await db.insert(downloaders).values({ - id: input.id, - name: input.name, - tokenHash: input.tokenHash, - tokenJti: input.tokenJti, - status: 'offline', - enabled: true, - version: input.version, - hostname: input.hostname, - platform: input.platform, - arch: input.arch, - engine: input.engine, - capabilities: JSON.stringify(input.capabilities), - maxConcurrentTasks: input.maxConcurrentTasks, - currentTasks: input.currentTasks, - downloadBps: input.downloadBps, - uploadBps: input.uploadBps, - freeDiskBytes: input.freeDiskBytes, - remoteDownloadCreditBillingEnabled: false, - remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes, - remoteDownloadCreditPerUnit: DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT, - lastHeartbeatAt: null, - createdBy: input.createdBy, - createdAt: input.now, - updatedAt: input.now, - }) + await db.insert(downloaders).values(downloaderInsertValues(input)) }, async list() { diff --git a/server/auth.ts b/server/auth.ts index e0d6b9ee..ea669555 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -40,6 +40,8 @@ import { generateUserOrgSlug, isPersonalOrgLike } from '../shared/org-slugs' import { createEmailGateway } from './adapters/gateways/email' import { deleteApiKeysScopedToOrganization } from './adapters/repos/api-key-scopes' import { createAuditRepo } from './adapters/repos/audit' +import { createDownloadTokenGateway } from './adapters/repos/download-tokens' +import { createDownloaderBootstrapCredentialRepo } from './adapters/repos/downloader-bootstrap' import { createInviteRepo } from './adapters/repos/invite' import { createLicenseBindingRepo } from './adapters/repos/license-binding' import { createMemberCountRepo } from './adapters/repos/member-count' @@ -54,6 +56,11 @@ import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema' import { executeWriteTransaction } from './db/transaction' import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig } from './domain/captcha' import { EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, isEmailVerificationRequired } from './domain/email-verification' +import { + LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG, + LEGACY_DOWNLOADER_CLIENT_ID, + LEGACY_DOWNLOADER_REGISTER_SCOPE, +} from './domain/legacy-downloader-bootstrap' import { currentTrafficPeriod } from './domain/quota' import { recordAuditEffect } from './lib/audit' import { isLocalNetworkOrigin } from './lib/local-origin' @@ -227,6 +234,12 @@ function stringValue(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } +function returnedAccessToken(value: unknown): string | null { + const returned = recordValue(value) + const token = returned?.access_token + return typeof token === 'string' && token.length > 0 ? token : null +} + async function ensureUserRegistrationAudit(db: Database, userId: string, firstAccountId?: string): Promise { const [firstAccount] = await db .select({ id: authSchema.account.id, providerId: authSchema.account.providerId }) @@ -321,6 +334,8 @@ export async function createAuth( const dbProxy = platformProxy ? platformProxy.db : createDbProxy(rawDb) const db = dbProxy + const downloadTokens = createDownloadTokenGateway() + const downloaderBootstrapCredentials = createDownloaderBootstrapCredentialRepo(db, downloadTokens) // The email gateway needs a Platform for the Cloudflare EMAIL binding. On the // bare-Database path (tests, Node fallbacks) there is no platform, so wrap the // db proxy in a binding-free Platform — matching the previous behaviour where @@ -395,6 +410,19 @@ export async function createAuth( if (ctx.path === '/delete-user') { throw new APIError('FORBIDDEN', { message: 'Self-service account deletion is not available' }) } + if (ctx.path === '/device/code') { + const body = ctx.body as Record | undefined + if (body?.client_id !== LEGACY_DOWNLOADER_CLIENT_ID) { + throw new APIError('BAD_REQUEST', { error: 'invalid_client', error_description: 'Invalid client ID' }) + } + if (body.scope !== LEGACY_DOWNLOADER_REGISTER_SCOPE) { + throw new APIError('BAD_REQUEST', { + error: 'invalid_request', + error_description: 'Invalid downloader registration scope', + }) + } + return + } if (ctx.path !== '/api-key/create') return const body = ctx.body as Record | undefined @@ -431,6 +459,37 @@ export async function createAuth( // failed, so it skips anything that returned an APIError. after: createAuthMiddleware(async (ctx) => { if (ctx.context.returned instanceof APIError) return + if (ctx.path === '/device/token') { + const accessToken = returnedAccessToken(ctx.context.returned) + const returned = recordValue(ctx.context.returned) + const body = ctx.body as Record | undefined + if ( + accessToken && + returned?.scope === LEGACY_DOWNLOADER_REGISTER_SCOPE && + typeof body?.device_code === 'string' + ) { + const [bootstrapSession] = await db + .select({ userId: authSchema.session.userId, expiresAt: authSchema.session.expiresAt }) + .from(authSchema.session) + .where(eq(authSchema.session.token, accessToken)) + .limit(1) + if (bootstrapSession) { + await Promise.all([ + downloaderBootstrapCredentials.issue({ + platform: authPlatform, + token: accessToken, + userId: bootstrapSession.userId, + deviceCode: body.device_code, + expiresAt: bootstrapSession.expiresAt, + }), + db + .update(authSchema.session) + .set({ activeOrganizationId: LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG }) + .where(eq(authSchema.session.token, accessToken)), + ]) + } + } + } const session = await getSessionFromCtx(ctx) const actorId = session?.user?.id if (!actorId) return @@ -545,7 +604,7 @@ export async function createAuth( deviceAuthorization({ schema: {}, verificationUri: '/device', - validateClient: async (clientId) => clientId === 'zpan-cli', + validateClient: async (clientId) => clientId === LEGACY_DOWNLOADER_CLIENT_ID, }), apiKey([ { diff --git a/server/composition.ts b/server/composition.ts index 809840b7..0aa1e1d7 100644 --- a/server/composition.ts +++ b/server/composition.ts @@ -25,6 +25,7 @@ import { createCloudTrafficReportRepo } from './adapters/repos/cloud-traffic-rep import { createDownloadTaskRepo } from './adapters/repos/download-task' import { createDownloadTokenGateway } from './adapters/repos/download-tokens' import { createDownloaderRepo } from './adapters/repos/downloader' +import { createDownloaderBootstrapCredentialRepo } from './adapters/repos/downloader-bootstrap' import { createImageHostingRepo } from './adapters/repos/image-hosting' import { createImageHostingConfigRepo } from './adapters/repos/image-hosting-config' import { createInstanceRepo } from './adapters/repos/instance' @@ -75,6 +76,7 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}): distributed: cacheNamespace ? createCloudflareKvBackend(cacheNamespace) : undefined, }) const storages = createStorageRepo(db, cache) + const downloadTokens = createDownloadTokenGateway() return { audit: createAuditRepo(db), adminStats: createAdminStatsRepo(db), @@ -89,8 +91,9 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}): cloudStore: createCloudStoreRepo(db), cloudTrafficReports: createCloudTrafficReportRepo(db), downloaders: createDownloaderRepo(db), + downloaderBootstrapCredentials: createDownloaderBootstrapCredentialRepo(db, downloadTokens), downloadTasks: createDownloadTaskRepo(db), - downloadTokens: createDownloadTokenGateway(), + downloadTokens, email: createEmailGateway(systemOptions), invites: createInviteRepo(db), imageHostingConfigs: createImageHostingConfigRepo(db), diff --git a/server/db/auth-schema.test.ts b/server/db/auth-schema.test.ts index 2782ada4..5a99b87e 100644 --- a/server/db/auth-schema.test.ts +++ b/server/db/auth-schema.test.ts @@ -1,5 +1,6 @@ +import { getTableConfig } from 'drizzle-orm/sqlite-core' import { describe, expect, it } from 'vitest' -import { user } from './auth-schema.js' +import { downloaderBootstrapCredential, user } from './auth-schema.js' describe('auth-schema user table', () => { it('has a username column', () => { @@ -42,3 +43,46 @@ describe('auth-schema user table', () => { expect(user.displayUsername.notNull).toBe(false) }) }) + +describe('downloaderBootstrapCredential table', () => { + it('stores the bootstrap token hash as a unique text column', () => { + expect(downloaderBootstrapCredential.tokenHash.name).toBe('token_hash') + expect(downloaderBootstrapCredential.tokenHash.columnType).toBe('SQLiteText') + expect(downloaderBootstrapCredential.tokenHash.notNull).toBe(true) + expect(downloaderBootstrapCredential.tokenHash.isUnique).toBe(true) + }) + + it('stores required downloader bootstrap metadata', () => { + expect(downloaderBootstrapCredential.userId.name).toBe('user_id') + expect(downloaderBootstrapCredential.userId.notNull).toBe(true) + expect(downloaderBootstrapCredential.deviceCode.name).toBe('device_code') + expect(downloaderBootstrapCredential.deviceCode.notNull).toBe(true) + expect(downloaderBootstrapCredential.clientId.name).toBe('client_id') + expect(downloaderBootstrapCredential.clientId.notNull).toBe(true) + expect(downloaderBootstrapCredential.scope.name).toBe('scope') + expect(downloaderBootstrapCredential.scope.notNull).toBe(true) + }) + + it('tracks expiry, optional consumption, and creation timestamps', () => { + expect(downloaderBootstrapCredential.expiresAt.name).toBe('expires_at') + expect(downloaderBootstrapCredential.expiresAt.columnType).toBe('SQLiteTimestamp') + expect(downloaderBootstrapCredential.expiresAt.notNull).toBe(true) + expect(downloaderBootstrapCredential.consumedAt.name).toBe('consumed_at') + expect(downloaderBootstrapCredential.consumedAt.columnType).toBe('SQLiteTimestamp') + expect(downloaderBootstrapCredential.consumedAt.notNull).toBe(false) + expect(downloaderBootstrapCredential.createdAt.name).toBe('created_at') + expect(downloaderBootstrapCredential.createdAt.notNull).toBe(true) + }) + + it('declares the bootstrap lookup indexes and user foreign key', () => { + const { foreignKeys, indexes } = getTableConfig(downloaderBootstrapCredential) + + expect(indexes.map((index) => index.config.name).sort()).toEqual([ + 'downloader_bootstrap_consumed_idx', + 'downloader_bootstrap_token_hash_idx', + 'downloader_bootstrap_user_idx', + ]) + expect(foreignKeys).toHaveLength(1) + expect(foreignKeys[0].reference().foreignColumns[0].name).toBe('id') + }) +}) diff --git a/server/db/auth-schema.ts b/server/db/auth-schema.ts index 955c4907..b6793aca 100644 --- a/server/db/auth-schema.ts +++ b/server/db/auth-schema.ts @@ -216,6 +216,30 @@ export const deviceCode = sqliteTable( ], ) +export const downloaderBootstrapCredential = sqliteTable( + 'downloader_bootstrap_credentials', + { + id: text('id').primaryKey(), + tokenHash: text('token_hash').notNull().unique(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + deviceCode: text('device_code').notNull(), + clientId: text('client_id').notNull(), + scope: text('scope').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(), + consumedAt: integer('consumed_at', { mode: 'timestamp_ms' }), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (table) => [ + index('downloader_bootstrap_token_hash_idx').on(table.tokenHash), + index('downloader_bootstrap_user_idx').on(table.userId), + index('downloader_bootstrap_consumed_idx').on(table.consumedAt), + ], +) + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), diff --git a/server/domain/legacy-downloader-bootstrap.ts b/server/domain/legacy-downloader-bootstrap.ts new file mode 100644 index 00000000..18615252 --- /dev/null +++ b/server/domain/legacy-downloader-bootstrap.ts @@ -0,0 +1,12 @@ +export const LEGACY_DOWNLOADER_CLIENT_ID = 'zpan-cli' +export const LEGACY_DOWNLOADER_REGISTER_SCOPE = 'downloader:register' +export const LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG = '__zpan_legacy_downloader_bootstrap__' + +export function isLegacyDownloaderBootstrapSession(session: { activeOrganizationId?: string } | undefined): boolean { + return session?.activeOrganizationId === LEGACY_DOWNLOADER_BOOTSTRAP_SESSION_ORG +} + +export function isDownloaderBootstrapRegistrationRequest(method: string, path: string): boolean { + const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path + return method.toUpperCase() === 'POST' && normalizedPath === '/api/downloads/downloaders' +} diff --git a/server/http/downloads/download-tasks.integration.test.ts b/server/http/downloads/download-tasks.integration.test.ts index d8676599..727cbd34 100644 --- a/server/http/downloads/download-tasks.integration.test.ts +++ b/server/http/downloads/download-tasks.integration.test.ts @@ -92,9 +92,8 @@ async function seedCloudBinding(db: Awaited>['d await seedBusinessLicense(db) } -async function registerDownloaderThroughDeviceLogin( +async function issueDownloaderBootstrapToken( app: Awaited>['app'], - name: string, headers?: { Cookie: string }, ) { const admin = headers ?? (await adminHeaders(app)) @@ -130,11 +129,21 @@ async function registerDownloaderThroughDeviceLogin( }), }) expect(tokenRes.status).toBe(200) - const token = (await tokenRes.json()) as { access_token: string } + const token = (await tokenRes.json()) as { access_token: string; scope: string } + expect(token.scope).toBe('downloader:register') + return token.access_token +} + +async function registerDownloaderThroughDeviceLogin( + app: Awaited>['app'], + name: string, + headers?: { Cookie: string }, +) { + const token = await issueDownloaderBootstrapToken(app, headers) const createDownloaderRes = await app.request('/api/downloads/downloaders', { method: 'POST', - headers: { Authorization: `Bearer ${token.access_token}`, 'Content-Type': 'application/json' }, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, heartbeat }), }) expect(createDownloaderRes.status).toBe(201) @@ -174,6 +183,105 @@ describe('Download tasks API integration', () => { const created = await registerDownloaderThroughDeviceLogin(app, 'device-login-downloader') expect(created.downloader.name).toBe('device-login-downloader') expect(created.token).toBeTruthy() + + await recordDownloaderHeartbeat(app, created.token) + }) + + it('requires the exact legacy downloader client and scope [spec: download-tasks/device-bootstrap-scope]', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + + const wrongScope = await app.request('/api/auth/device/code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: 'zpan-cli', scope: 'objects:read' }), + }) + expect(wrongScope.status).toBe(400) + + const missingScope = await app.request('/api/auth/device/code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: 'zpan-cli' }), + }) + expect(missingScope.status).toBe(400) + + const wrongClient = await app.request('/api/auth/device/code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: 'zpan-agent', scope: 'downloader:register' }), + }) + expect(wrongClient.status).toBe(400) + }) + + it('limits downloader bootstrap tokens to one successful registration [spec: download-tasks/device-bootstrap-single-use]', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await insertStorage(db) + const token = await issueDownloaderBootstrapToken(app) + + const first = await app.request('/api/downloads/downloaders', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'first-bootstrap-registration', heartbeat }), + }) + expect(first.status).toBe(201) + const registered = (await first.json()) as { token: string } + await recordDownloaderHeartbeat(app, registered.token) + + const replay = await app.request('/api/downloads/downloaders', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'replayed-bootstrap-registration', heartbeat }), + }) + expect(replay.status).toBe(401) + }) + + it('keeps bootstrap credentials usable when transactional registration fails [spec: download-tasks/device-bootstrap-rollback]', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await insertStorage(db) + const token = await issueDownloaderBootstrapToken(app) + await db.run(sql` + CREATE TRIGGER fail_downloader_insert + BEFORE INSERT ON downloaders + BEGIN + SELECT RAISE(ABORT, 'forced_downloader_insert_failure'); + END; + `) + + const failed = await app.request('/api/downloads/downloaders', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'failed-bootstrap-registration', heartbeat }), + }) + expect(failed.status).toBe(500) + await db.run(sql`DROP TRIGGER fail_downloader_insert`) + + const retry = await app.request('/api/downloads/downloaders', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'retried-bootstrap-registration', heartbeat }), + }) + expect(retry.status).toBe(201) + }) + + it('rejects downloader bootstrap tokens on non-registration APIs [spec: download-tasks/device-bootstrap-silo]', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await insertStorage(db) + const token = await issueDownloaderBootstrapToken(app) + const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } + + const denied = await Promise.all([ + app.request('/api/downloads/downloaders', { headers }), + app.request('/api/downloads/downloaders/me/heartbeats', { + method: 'POST', + headers, + body: JSON.stringify(heartbeat), + }), + app.request('/api/downloads/tasks', { headers }), + app.request('/api/objects', { headers }), + app.request('/api/quotas/me', { headers }), + app.request('/api/shares', { headers }), + ]) + + expect(denied.map((res) => res.status)).toEqual([401, 401, 401, 401, 401, 401]) }) it('continues task listings with an opaque page token without duplicates', async () => { diff --git a/server/http/downloads/downloaders.ts b/server/http/downloads/downloaders.ts index 65b92f3f..befae8d4 100644 --- a/server/http/downloads/downloaders.ts +++ b/server/http/downloads/downloaders.ts @@ -11,10 +11,11 @@ import { } from '@shared/schemas' import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants' import { hasFeature } from '../../domain/licensing' -import { requireAdmin, requireDownloader } from '../../middleware/auth' +import { requireAdmin, requireDownloader, requireDownloaderRegistration } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { createDownloader, + createDownloaderWithBootstrapCredential, deleteDownloader, listDownloaders, recordDownloaderHeartbeat, @@ -44,14 +45,14 @@ const listRoute = authRoute( ) const createRouteDoc = authRoute( - { access: 'admin' }, + { access: 'anyOf', policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }] }, { operationId: 'createDownloader', summary: 'Register downloader', tags: ['Downloaders'], method: 'post', path: '/', - middleware: [requireAdmin] as const, + middleware: [requireDownloaderRegistration] as const, request: jsonBody(createDownloaderSchema), responses: { 201: jsonContent(createDownloaderResponseSchema, 'Downloader registration'), @@ -154,7 +155,12 @@ const downloadersRoute = new OpenAPIHono() }, }) } - const result = await createDownloader(deps, c.get('platform'), c.req.valid('json'), userId) + const principal = c.get('principal') + const input = c.req.valid('json') + const result = + principal?.kind === 'downloader-bootstrap' + ? await createDownloaderWithBootstrapCredential(deps, c.get('platform'), input, userId, principal.sessionToken) + : await createDownloader(deps, c.get('platform'), input, userId) return c.json(result, 201) }) .openapi(updateRoute, async (c) => { diff --git a/server/http/openapi.ts b/server/http/openapi.ts index 87f3b1fb..88fe9a28 100644 --- a/server/http/openapi.ts +++ b/server/http/openapi.ts @@ -55,7 +55,9 @@ function openApiSecurity(auth: RouteAuthorizationDeclaration): Record { + it('records downloader bootstrap principals as user actors', () => { + const principal: AuthPrincipal = { + kind: 'downloader-bootstrap', + userId: 'user-1', + sessionToken: 'bootstrap-token', + scope: 'downloader:register', + authMethod: 'bearer', + } + + expect(auditActor(principal)).toEqual({ + userId: 'user-1', + actorType: 'user', + actorRef: null, + }) + }) +}) diff --git a/server/middleware/audit-actor.ts b/server/middleware/audit-actor.ts index fcc6b5af..ef1bbd59 100644 --- a/server/middleware/audit-actor.ts +++ b/server/middleware/audit-actor.ts @@ -12,5 +12,8 @@ export function auditActor(principal: AuthPrincipal | null): AuditActor { if (principal.kind === 'downloader') { return { userId: null, actorType: 'downloader', actorRef: principal.downloaderId } } + if (principal.kind === 'downloader-bootstrap') { + return { userId: principal.userId, actorType: 'user', actorRef: null } + } return { userId: principal.createdByUserId, actorType: 'task-upload', actorRef: principal.taskId } } diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 88a0db2d..393d0a50 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,5 +1,11 @@ import { isAuthorizationScope, permissionScopes } from '@shared/authorization' import { createMiddleware } from 'hono/factory' +import { + isDownloaderBootstrapRegistrationRequest, + isLegacyDownloaderBootstrapSession, + LEGACY_DOWNLOADER_CLIENT_ID, + LEGACY_DOWNLOADER_REGISTER_SCOPE, +} from '../domain/legacy-downloader-bootstrap' import { ApiKeyRateLimitError, type CachePolicy, forbidden, rateLimited, unauthorized } from '../usecases/ports' import { anonymousAuthzContext, type Env } from './platform' @@ -110,10 +116,40 @@ export const authMiddleware = createMiddleware(async (c, next) => { await next() return } + const bootstrap = await deps.downloaderBootstrapCredentials.resolve(platform, token, new Date()) + if (bootstrap) { + c.set('userId', bootstrap.userId) + c.set('userRole', null) + c.set('orgId', null) + c.set('principal', { + kind: 'downloader-bootstrap', + userId: bootstrap.userId, + sessionToken: token, + scope: LEGACY_DOWNLOADER_REGISTER_SCOPE, + authMethod: 'bearer', + }) + c.set('authzContext', { + credential: 'downloader-bootstrap', + userId: bootstrap.userId, + orgId: null, + fixedOrgId: null, + grantedScopes: new Set(), + actor: { type: 'user', ref: bootstrap.userId }, + state: { clientId: LEGACY_DOWNLOADER_CLIENT_ID, scope: LEGACY_DOWNLOADER_REGISTER_SCOPE }, + }) + if (!bootstrap.active || !isDownloaderBootstrapRegistrationRequest(c.req.method, c.req.path)) { + throw unauthorized('Unauthorized') + } + await next() + return + } + await next() + return } const auth = c.get('auth') const result = (await auth.api.getSession({ headers: c.req.raw.headers })) as SessionWithPlugins | null + if (isLegacyDownloaderBootstrapSession(result?.session)) throw unauthorized('Unauthorized') c.set('userId', result?.user?.id ?? null) c.set('userRole', result?.user?.role ?? null) @@ -180,6 +216,22 @@ export const requireAdmin = createMiddleware(async (c, next) => { await next() }) +export const requireDownloaderRegistration = createMiddleware(async (c, next) => { + const principal = c.get('principal') + if (principal?.kind === 'downloader-bootstrap') { + await next() + return + } + if (principal?.kind !== 'user') throw unauthorized('Unauthorized') + const freshSession = (await c.get('auth').api.getSession({ + headers: c.req.raw.headers, + query: { disableCookieCache: true }, + })) as SessionWithPlugins | null + if (!freshSession?.user?.id) throw unauthorized('Unauthorized') + if (freshSession.user.role !== 'admin') throw forbidden('Forbidden') + await next() +}) + // requireTeamRole enforces a minimum role level for the current org. // Personal orgs bypass the check — the owner of a personal space has full access. // Must be used after requireAuth so orgId and userId are guaranteed non-null. diff --git a/server/middleware/authz.integration.test.ts b/server/middleware/authz.integration.test.ts index 13ca25e9..61a8b84a 100644 --- a/server/middleware/authz.integration.test.ts +++ b/server/middleware/authz.integration.test.ts @@ -55,9 +55,7 @@ async function getUserId(db: TestDb, email: string): Promise { return rows[0].id } -// Registers a downloader and returns its bearer token. Mirrors the device-login -// flow the CLI uses; needed to mint a `downloader` principal. -async function registerDownloader(app: TestApp, name: string): Promise { +async function issueBootstrapToken(app: TestApp): Promise { const admin = await adminHeaders(app) const codeRes = await app.request('/api/auth/device/code', { method: 'POST', @@ -65,7 +63,6 @@ async function registerDownloader(app: TestApp, name: string): Promise { body: JSON.stringify({ client_id: 'zpan-cli', scope: 'downloader:register' }), }) const code = (await codeRes.json()) as { device_code: string; user_code: string } - // Claim the user code with the admin session before approving (device flow). await app.request(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`, { headers: admin }) await app.request('/api/auth/device/approve', { method: 'POST', @@ -82,9 +79,16 @@ async function registerDownloader(app: TestApp, name: string): Promise { }), }) const token = (await tokenRes.json()) as { access_token: string } + return token.access_token +} + +// Registers a downloader and returns its bearer token. Mirrors the device-login +// flow the CLI uses; needed to mint a `downloader` principal. +async function registerDownloader(app: TestApp, name: string): Promise { + const accessToken = await issueBootstrapToken(app) const createRes = await app.request('/api/downloads/downloaders', { method: 'POST', - headers: { Authorization: `Bearer ${token.access_token}`, 'Content-Type': 'application/json' }, + headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, heartbeat: { @@ -191,6 +195,42 @@ describe('requirePermission middleware', () => { expect(body.error.status).toBe('UNAUTHENTICATED') }) + it('returns 401 for a bootstrap bearer on routes that would allow a session principal', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + mountProbes(app) + const bootstrapToken = await issueBootstrapToken(app) + + const res = await app.request('/api/test-authz/api-perm', { + headers: { Authorization: `Bearer ${bootstrapToken}` }, + }) + expect(res.status).toBe(401) + const body = (await res.json()) as { error: { message: string; status: string } } + expect(body.error.message).toBe('Unauthorized') + expect(body.error.status).toBe('UNAUTHENTICATED') + }) + + it('does not normalize untracked Better Auth bearer sessions as user principals', async () => { + const { app, db } = await createTestApp() + mountProbes(app) + const cookieHeaders = await authedHeaders(app, 'bearer-session@example.com') + const cookieAllowed = await app.request('/api/test-authz/api-perm', { headers: cookieHeaders }) + expect(cookieAllowed.status).toBe(200) + + const [session] = await db.all<{ token: string }>(sql` + SELECT s.token + FROM session s + INNER JOIN user u ON u.id = s.user_id + WHERE u.email = 'bearer-session@example.com' + ORDER BY s.created_at DESC + LIMIT 1 + `) + + const bearerDenied = await app.request('/api/test-authz/api-perm', { + headers: { Authorization: `Bearer ${session.token}` }, + }) + expect(bearerDenied.status).toBe(401) + }) + it('returns 403 when a team member role is below the required minTeamRole', async () => { const { app, db } = await createTestApp() mountProbes(app) diff --git a/server/middleware/authz.ts b/server/middleware/authz.ts index 888b30fd..17da7316 100644 --- a/server/middleware/authz.ts +++ b/server/middleware/authz.ts @@ -20,6 +20,7 @@ export type RouteAuthorizationDeclaration = | { access: 'admin' } | { access: 'session'; minTeamRole?: TeamRole } | { access: 'downloader' } + | { access: 'downloader-bootstrap' } | { access: 'signed-webhook' } | { access: 'task-upload-token' } | { access: 'anyOf'; policies: readonly RouteAuthorizationDeclaration[] } @@ -63,6 +64,7 @@ export async function evaluateAuthorization(input: { ? { allowed: true, effectiveOrgId: null, reason: 'allowed' } : deny(context, 401, 'actor_not_allowed', declaration) } + if (context.credential === 'downloader-bootstrap') return deny(context, 401, 'actor_not_allowed', declaration) const requiredScopes = declaration.scopes ?? [] if (context.grantedScopes) { diff --git a/server/middleware/platform.ts b/server/middleware/platform.ts index b92b7bb3..20afb0bc 100644 --- a/server/middleware/platform.ts +++ b/server/middleware/platform.ts @@ -57,6 +57,13 @@ export type AuthPrincipal = downloaderId: string authMethod: 'bearer' } + | { + kind: 'downloader-bootstrap' + userId: string + sessionToken: string + scope: 'downloader:register' + authMethod: 'bearer' + } | { kind: 'download-task-upload' downloaderId: string @@ -97,6 +104,15 @@ export type AuthzContext = actor: { type: 'downloader'; ref: string } state: Record } + | { + credential: 'downloader-bootstrap' + userId: string + orgId: null + fixedOrgId: null + grantedScopes: ReadonlySet + actor: { type: 'user'; ref: string } + state: { clientId: 'zpan-cli'; scope: 'downloader:register' } + } | { credential: 'download-task-upload' userId: string diff --git a/server/openapi.test.ts b/server/openapi.test.ts index e89af626..8d8a9b1f 100644 --- a/server/openapi.test.ts +++ b/server/openapi.test.ts @@ -148,6 +148,21 @@ describe('global OpenAPI document', () => { expect(findOperationsMissingAuthContract(handWrittenPaths)).toEqual([]) }) + it('documents downloader registration as admin or one-purpose bootstrap bearer auth', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const res = await app.request('/api/openapi.json') + const doc = (await res.json()) as { + paths: Record> + } + + const operation = doc.paths['/api/downloads/downloaders']?.post + expect(operation?.security).toEqual([{ cookieAuth: [] }, { bearerAuth: [] }]) + expect(operation?.['x-zpan-auth']).toEqual({ + access: 'anyOf', + policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }], + }) + }) + it('documents owner role requirements for store operations that enforce owner team role', async () => { const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) const res = await app.request('/api/openapi.json') diff --git a/server/test/setup.ts b/server/test/setup.ts index 23f21885..67eefacc 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -114,6 +114,20 @@ const AUTH_SCHEMA_SQL = ` 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); + CREATE TABLE IF NOT EXISTS downloader_bootstrap_credentials ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + device_code TEXT NOT NULL, + client_id TEXT NOT NULL, + scope TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) + ); + CREATE INDEX IF NOT EXISTS downloader_bootstrap_token_hash_idx ON downloader_bootstrap_credentials(token_hash); + CREATE INDEX IF NOT EXISTS downloader_bootstrap_user_idx ON downloader_bootstrap_credentials(user_id); + CREATE INDEX IF NOT EXISTS downloader_bootstrap_consumed_idx ON downloader_bootstrap_credentials(consumed_at); ` const APP_SCHEMA_SQL = ` diff --git a/server/usecases/deps.ts b/server/usecases/deps.ts index faa000ac..64486735 100644 --- a/server/usecases/deps.ts +++ b/server/usecases/deps.ts @@ -14,6 +14,7 @@ import type { ChangelogProvider, CloudStoreRepo, CloudTrafficReportRepo, + DownloaderBootstrapCredentialRepo, DownloaderRepo, DownloadTaskRepo, DownloadTokenGateway, @@ -66,6 +67,7 @@ export interface Deps { cloudStore: CloudStoreRepo cloudTrafficReports: CloudTrafficReportRepo downloaders: DownloaderRepo + downloaderBootstrapCredentials: DownloaderBootstrapCredentialRepo downloadTasks: DownloadTaskRepo downloadTokens: DownloadTokenGateway email: EmailGateway diff --git a/server/usecases/downloads/downloads.test.ts b/server/usecases/downloads/downloads.test.ts index be78c72c..995fac9d 100644 --- a/server/usecases/downloads/downloads.test.ts +++ b/server/usecases/downloads/downloads.test.ts @@ -1,9 +1,15 @@ +import type { CreateDownloaderInput } from '@shared/schemas' import type { BindingState, Downloader } from '@shared/types' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DownloaderRecord, DownloaderRepo } from '../ports' import { type AppError, DownloadError } from '../ports' import { loadBindingState } from '../site/licensing' -import { type DownloadsDeps, downloaderHeartbeatPersistence, updateDownloaderCreditBilling } from './downloads' +import { + createDownloaderWithBootstrapCredential, + type DownloadsDeps, + downloaderHeartbeatPersistence, + updateDownloaderCreditBilling, +} from './downloads' vi.mock('../site/licensing', () => ({ loadBindingState: vi.fn() })) @@ -64,6 +70,7 @@ function makeDeps(downloaders: Partial = {}) { return { deps: { downloaders: repo, + downloaderBootstrapCredentials: {}, downloadTasks: {}, downloadTokens: {}, licenseBinding: {}, @@ -197,3 +204,101 @@ describe('updateDownloaderCreditBilling', () => { expect(update).not.toHaveBeenCalled() }) }) + +describe('createDownloaderWithBootstrapCredential', () => { + const input = { + name: 'Bootstrap downloader', + heartbeat: { + version: '1.2.3', + hostname: 'bootstrap-edge', + platform: 'linux', + arch: 'amd64', + engine: 'aria2', + capabilities: ['http'], + maxConcurrentTasks: 2, + currentTasks: 0, + downloadBps: 0, + uploadBps: 0, + freeDiskBytes: 4_096, + }, + } satisfies CreateDownloaderInput + + it('returns the created downloader and token after bootstrap registration succeeds', async () => { + const platform = { + db: {} as never, + getEnv: () => undefined, + getBinding: () => undefined, + } + const get = vi.fn(async () => downloader) + const registerDownloader = vi.fn(async () => true) + const deps = { + ...makeDeps({ get }).deps, + downloaderBootstrapCredentials: { + issue: vi.fn(), + resolve: vi.fn(), + consume: vi.fn(), + registerDownloader, + }, + downloadTokens: { + signDownloadToken: vi.fn(async () => 'signed-token'), + hashDownloadToken: vi.fn(async () => 'hashed-token'), + verifyDownloadToken: vi.fn(), + resolveDownloaderToken: vi.fn(), + resolveTaskUploadToken: vi.fn(), + }, + } satisfies DownloadsDeps + + await expect( + createDownloaderWithBootstrapCredential(deps, platform, input, 'user-1', 'bootstrap-token'), + ).resolves.toEqual({ + downloader, + token: 'signed-token', + }) + expect(get).toHaveBeenCalledWith(expect.any(String)) + }) + + it('rejects when the bootstrap credential cannot be consumed during registration', async () => { + const platform = { + db: {} as never, + getEnv: () => undefined, + getBinding: () => undefined, + } + const get = vi.fn(async () => downloader) + const registerDownloader = vi.fn(async () => false) + const deps = { + ...makeDeps({ get }).deps, + downloaderBootstrapCredentials: { + issue: vi.fn(), + resolve: vi.fn(), + consume: vi.fn(), + registerDownloader, + }, + downloadTokens: { + signDownloadToken: vi.fn(async () => 'signed-token'), + hashDownloadToken: vi.fn(async () => 'hashed-token'), + verifyDownloadToken: vi.fn(), + resolveDownloaderToken: vi.fn(), + resolveTaskUploadToken: vi.fn(), + }, + } satisfies DownloadsDeps + + await expect( + createDownloaderWithBootstrapCredential(deps, platform, input, 'user-1', 'bootstrap-token'), + ).rejects.toMatchObject({ + name: 'AppError', + httpStatus: 401, + } satisfies Partial) + + expect(registerDownloader).toHaveBeenCalledWith({ + platform, + token: 'bootstrap-token', + now: expect.any(Date), + downloader: expect.objectContaining({ + name: input.name, + createdBy: 'user-1', + tokenHash: 'hashed-token', + }), + }) + expect(get).not.toHaveBeenCalled() + }) +}) diff --git a/server/usecases/downloads/downloads.ts b/server/usecases/downloads/downloads.ts index f00549d0..7374681b 100644 --- a/server/usecases/downloads/downloads.ts +++ b/server/usecases/downloads/downloads.ts @@ -25,6 +25,7 @@ import type { Platform } from '../../platform/interface' import type { AuditEvent, AuditRepo, + DownloaderBootstrapCredentialRepo, DownloaderRecord, DownloaderRepo, DownloadTaskRecord, @@ -38,7 +39,7 @@ import type { StorageRepo, UpdateDownloadTaskFields, } from '../ports' -import { DownloadError, featureBlocked } from '../ports' +import { DownloadError, featureBlocked, unauthorized } from '../ports' import { loadBindingState } from '../site/licensing' import { ensureDownloadFolderPath } from './download-folders' import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage' @@ -51,6 +52,7 @@ import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './r export type DownloadsDeps = { downloaders: DownloaderRepo + downloaderBootstrapCredentials: DownloaderBootstrapCredentialRepo downloadTasks: DownloadTaskRepo downloadTokens: DownloadTokenGateway licenseBinding: LicenseBindingRepo @@ -125,8 +127,38 @@ export async function createDownloader( platform: Platform, input: CreateDownloaderInput, userId: string, +): Promise<{ downloader: Downloader; token: string }> { + const registration = await prepareDownloaderRegistration(deps, platform, input, userId, new Date()) + await deps.downloaders.insert(registration.record) + return { downloader: await deps.downloaders.get(registration.record.id), token: registration.token } +} + +export async function createDownloaderWithBootstrapCredential( + deps: DownloadsDeps, + platform: Platform, + input: CreateDownloaderInput, + userId: string, + bootstrapToken: string, ): Promise<{ downloader: Downloader; token: string }> { const now = new Date() + const registration = await prepareDownloaderRegistration(deps, platform, input, userId, now) + const registered = await deps.downloaderBootstrapCredentials.registerDownloader({ + platform, + token: bootstrapToken, + now, + downloader: registration.record, + }) + if (!registered) throw unauthorized() + return { downloader: await deps.downloaders.get(registration.record.id), token: registration.token } +} + +async function prepareDownloaderRegistration( + deps: DownloadsDeps, + platform: Platform, + input: CreateDownloaderInput, + userId: string, + now: Date, +) { const id = nanoid() const jti = nanoid() const token = await deps.downloadTokens.signDownloadToken(platform, { @@ -136,27 +168,29 @@ export async function createDownloader( jti, iat: Math.floor(now.getTime() / 1000), }) - await deps.downloaders.insert({ - id, - name: input.name, - tokenHash: await deps.downloadTokens.hashDownloadToken(platform, token), - tokenJti: jti, - version: input.heartbeat.version, - hostname: input.heartbeat.hostname, - platform: input.heartbeat.platform, - arch: input.heartbeat.arch, - engine: input.heartbeat.engine, - capabilities: input.heartbeat.capabilities, - maxConcurrentTasks: input.heartbeat.maxConcurrentTasks, - currentTasks: input.heartbeat.currentTasks, - downloadBps: input.heartbeat.downloadBps, - uploadBps: input.heartbeat.uploadBps, - freeDiskBytes: input.heartbeat.freeDiskBytes, - remoteDownloadCreditUnitBytes: DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES, - createdBy: userId, - now, - }) - return { downloader: await deps.downloaders.get(id), token } + return { + record: { + id, + name: input.name, + tokenHash: await deps.downloadTokens.hashDownloadToken(platform, token), + tokenJti: jti, + version: input.heartbeat.version, + hostname: input.heartbeat.hostname, + platform: input.heartbeat.platform, + arch: input.heartbeat.arch, + engine: input.heartbeat.engine, + capabilities: input.heartbeat.capabilities, + maxConcurrentTasks: input.heartbeat.maxConcurrentTasks, + currentTasks: input.heartbeat.currentTasks, + downloadBps: input.heartbeat.downloadBps, + uploadBps: input.heartbeat.uploadBps, + freeDiskBytes: input.heartbeat.freeDiskBytes, + remoteDownloadCreditUnitBytes: DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES, + createdBy: userId, + now, + }, + token, + } } export async function listDownloaders(deps: DownloadsDeps): Promise { diff --git a/server/usecases/ports.ts b/server/usecases/ports.ts index d417ea42..26cd11af 100644 --- a/server/usecases/ports.ts +++ b/server/usecases/ports.ts @@ -16,6 +16,7 @@ export * from './ports/changelog' export * from './ports/cloud-store' export * from './ports/cloud-traffic-report' export * from './ports/download-tokens' +export * from './ports/downloader-bootstrap' export * from './ports/downloads' export * from './ports/email' export * from './ports/image-domain-provider' diff --git a/server/usecases/ports/downloader-bootstrap.ts b/server/usecases/ports/downloader-bootstrap.ts new file mode 100644 index 00000000..ba43ac44 --- /dev/null +++ b/server/usecases/ports/downloader-bootstrap.ts @@ -0,0 +1,27 @@ +import type { Platform } from '../../platform/interface' +import type { CreateDownloaderRecordInput } from './downloads' + +export interface DownloaderBootstrapCredential { + userId: string + clientId: 'zpan-cli' + scope: 'downloader:register' + active: boolean +} + +export interface DownloaderBootstrapCredentialRepo { + issue(input: { + platform: Platform + token: string + userId: string + deviceCode: string + expiresAt: Date + }): Promise + resolve(platform: Platform, token: string, now: Date): Promise + consume(platform: Platform, token: string, now: Date): Promise + registerDownloader(input: { + platform: Platform + token: string + now: Date + downloader: CreateDownloaderRecordInput + }): Promise +} diff --git a/spec/download-tasks.feature b/spec/download-tasks.feature index cd5505b8..2392e657 100644 --- a/spec/download-tasks.feature +++ b/spec/download-tasks.feature @@ -9,6 +9,30 @@ Feature: Remote download tasks When a downloader registers Then it is registered through BetterAuth device login + @download-tasks/device-bootstrap-scope @api + Scenario: The legacy downloader device flow requires its exact client and scope + Given a legacy device-code request + When the client id or scope differs from zpan-cli downloader registration + Then the request is rejected + + @download-tasks/device-bootstrap-single-use @api + Scenario: A downloader bootstrap token is single-use + Given an approved legacy downloader bootstrap token + When downloader registration succeeds + Then replaying the same bootstrap token is rejected + + @download-tasks/device-bootstrap-rollback @api + Scenario: Failed downloader registration does not consume the bootstrap token + Given an approved legacy downloader bootstrap token + When downloader registration fails inside the database transaction + Then the same bootstrap token can be retried successfully + + @download-tasks/device-bootstrap-silo @api + Scenario: Downloader bootstrap tokens cannot call non-registration APIs + Given an approved legacy downloader bootstrap token + When it is used on APIs other than downloader registration + Then those APIs reject it + @download-tasks/list-status-multi @api Scenario: Task listing accepts multiple status values Given tasks in several statuses