From 6ac3d0d103df92604a4422e0dfd14008420ff3ed Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 02:36:29 +0800 Subject: [PATCH 01/29] Add the project proxy token generation Store the highest issued proxy token generation on each project (projects.proxy_token_gen, default 0 = the legacy token). Deploys now bake the token for that generation from Postgres instead of reading the KV accept floor, and resolve-worker reports it as proxyTokenGen. Rollback and cutover replay add the issued token as the fallback on queue routing entries and the cron scheduler when the target Worker still holds an older token. Project deletion removes the proxy-token: floor. 0.25.0 and 0.26.0 run on the 0057 schema and ignore the new column, so they stay valid upgrade sources. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../void/test/unit/platform-installer.test.ts | 43 +- .../drizzle/0058_project_proxy_token_gen.sql | 1 + .../packages/api/drizzle/compatibility.json | 8 + .../api/drizzle/meta/0058_snapshot.json | 6524 +++++++++++++++++ .../packages/api/drizzle/meta/_journal.json | 7 + platform/packages/api/src/deploy/recovery.ts | 17 +- .../packages/api/src/deploy/upload-worker.ts | 8 +- .../packages/api/src/provisioning/cleanup.ts | 7 + .../packages/api/src/proxy-token-rotation.ts | 63 + platform/packages/api/src/proxy-token.ts | 13 - .../api/src/routes/internal-resolve-worker.ts | 4 +- platform/packages/api/src/routes/projects.ts | 18 +- platform/packages/api/src/schema.ts | 3 + .../api/test/integration/api-delete.test.ts | 43 + .../api/test/integration/api-deploy.test.ts | 6 +- .../api-internal-project-token.test.ts | 23 + .../api/test/integration/api-projects.test.ts | 86 +- .../integration/api-resolve-worker.test.ts | 2 + .../integration/deployment-recovery.test.ts | 86 + .../unit/admin-upgrade-compatibility.test.ts | 33 + .../api/test/unit/proxy-token.test.ts | 17 +- 21 files changed, 6969 insertions(+), 43 deletions(-) create mode 100644 platform/packages/api/drizzle/0058_project_proxy_token_gen.sql create mode 100644 platform/packages/api/drizzle/meta/0058_snapshot.json create mode 100644 platform/packages/api/src/proxy-token-rotation.ts diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 08b2ee23d..ef5401afc 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -2909,6 +2909,7 @@ describe('platform installer', () => { expect(database.migrations.slice(baseline.length).map((migration) => migration.tag)).toEqual([ '0056_email_shared_recipients', '0057_github_authority_constraints', + '0058_project_proxy_token_gen', ]); const rows = baseline.map((migration, index) => ({ id: index + 1, @@ -2934,7 +2935,7 @@ describe('platform installer', () => { '0.22.0', true, ); - expect(result.appliedMigrations).toBe(2); + expect(result.appliedMigrations).toBe(3); expect( accepted.queries.some((query) => query.includes('CREATE TRIGGER "void_shared_email_recipient_teardown_guard"'), @@ -2973,7 +2974,7 @@ describe('platform installer', () => { sourceVersion, true, ); - expect(result.appliedMigrations).toBe(1); + expect(result.appliedMigrations).toBe(2); expect( accepted.queries.some((query) => query.includes('"authorized_by_user_id" SET NOT NULL')), ).toBe(true); @@ -2981,6 +2982,44 @@ describe('platform installer', () => { }, ); + it.each(['0.25.0', '0.26.0'])( + 'upgrades a %s installation through the project proxy token generation', + async (sourceVersion) => { + const database = readPlatformRuntimeManifest().database; + const baseline = database.migrations.filter((migration) => migration.index <= 57); + expect(baseline.at(-1)?.tag).toBe('0057_github_authority_constraints'); + const rows = baseline.map((migration, index) => ({ + id: index + 1, + hash: migration.sha256, + created_at: migration.timestampMillis, + })); + const rejected = migrationConnection(rows); + await expect( + preparePlatformDatabaseConnection( + rejected, + getPlatformRuntimeDirectory(), + database, + '0.24.0', + true, + ), + ).rejects.toThrow('cannot safely roll back'); + expect(rejected.queries).not.toContain('BEGIN'); + const accepted = migrationConnection(rows); + const result = await preparePlatformDatabaseConnection( + accepted, + getPlatformRuntimeDirectory(), + database, + sourceVersion, + true, + ); + expect(result.appliedMigrations).toBe(1); + expect(accepted.queries.some((query) => query.includes('ADD COLUMN "proxy_token_gen"'))).toBe( + true, + ); + expect(accepted.rows).toHaveLength(database.migrations.length); + }, + ); + it('rejects the isolated legacy email0054 fork without rewriting its history or applying canonical migrations', async () => { const database = readPlatformRuntimeManifest().database; const commonPrefix = database.migrations.filter((migration) => migration.index <= 53); diff --git a/platform/packages/api/drizzle/0058_project_proxy_token_gen.sql b/platform/packages/api/drizzle/0058_project_proxy_token_gen.sql new file mode 100644 index 000000000..eae887bca --- /dev/null +++ b/platform/packages/api/drizzle/0058_project_proxy_token_gen.sql @@ -0,0 +1 @@ +ALTER TABLE "projects" ADD COLUMN "proxy_token_gen" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/platform/packages/api/drizzle/compatibility.json b/platform/packages/api/drizzle/compatibility.json index 869729275..3e5377562 100644 --- a/platform/packages/api/drizzle/compatibility.json +++ b/platform/packages/api/drizzle/compatibility.json @@ -57,6 +57,14 @@ { "platformVersion": "0.24.0", "schemaDigest": "3e3458ba1268853dc8e9a887270863557c2a4c5a5210f8615d247bf1f4bf6bcc" + }, + { + "platformVersion": "0.25.0", + "schemaDigest": "7581930465664b7c578518bea54f36a83d20fcc47d9528183419033930d7ff4a" + }, + { + "platformVersion": "0.26.0", + "schemaDigest": "7581930465664b7c578518bea54f36a83d20fcc47d9528183419033930d7ff4a" } ] } diff --git a/platform/packages/api/drizzle/meta/0058_snapshot.json b/platform/packages/api/drizzle/meta/0058_snapshot.json new file mode 100644 index 000000000..66107fc13 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0058_snapshot.json @@ -0,0 +1,6524 @@ +{ + "id": "7de15010-25c6-463f-9b9e-395589b18901", + "prevId": "075ff55d-533d-44d8-acba-66fa0e8d29ae", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_codes": { + "name": "auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "jwt": { + "name": "jwt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cli_state": { + "name": "cli_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.builds": { + "name": "builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executor": { + "name": "executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_id": { + "name": "github_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_attempt": { + "name": "github_run_attempt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_url": { + "name": "logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_builds_project_created": { + "name": "idx_builds_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_status": { + "name": "idx_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_user_status": { + "name": "idx_builds_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_project_github_run_id": { + "name": "idx_builds_project_github_run_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"builds\".\"github_run_id\" IS NOT NULL AND \"builds\".\"github_run_attempt\" IS NOT NULL AND \"builds\".\"status\" IN ('starting', 'building', 'deploying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "builds_project_id_projects_id_fk": { + "name": "builds_project_id_projects_id_fk", + "tableFrom": "builds", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_user_id_users_id_fk": { + "name": "builds_user_id_users_id_fk", + "tableFrom": "builds", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_deployment_id_deployments_id_fk": { + "name": "builds_deployment_id_deployments_id_fk", + "tableFrom": "builds", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_operations": { + "name": "deployment_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_active_id": { + "name": "original_active_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_digest": { + "name": "input_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_prefix": { + "name": "input_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_dispatch_at": { + "name": "next_dispatch_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seq": { + "name": "last_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inputs_deleted": { + "name": "inputs_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_operations_key": { + "name": "idx_deployment_operations_key", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_operations_dispatch": { + "name": "idx_deployment_operations_dispatch", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_dispatch_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_operations_project_id_projects_id_fk": { + "name": "deployment_operations_project_id_projects_id_fk", + "tableFrom": "deployment_operations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_url": { + "name": "commit_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_ref": { + "name": "git_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_hashes": { + "name": "asset_hashes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retained": { + "name": "retained", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applied_migrations": { + "name": "applied_migrations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundle_stored": { + "name": "bundle_stored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_user_id": { + "name": "billing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_project_id": { + "name": "idx_deployments_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_project_status": { + "name": "idx_deployments_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_created_id": { + "name": "idx_deployments_created_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_project_id_projects_id_fk": { + "name": "deployments_project_id_projects_id_fk", + "tableFrom": "deployments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_data": { + "name": "verification_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_project_id": { + "name": "idx_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_hostname_unique": { + "name": "domains_hostname_unique", + "nullsNotDistinct": false, + "columns": ["hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_destination_cleanups": { + "name": "email_destination_cleanups", + "schema": "", + "columns": { + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destination_cleanups_due": { + "name": "idx_email_destination_cleanups_due", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_destination_cleanups_status_check": { + "name": "email_destination_cleanups_status_check", + "value": "\"email_destination_cleanups\".\"status\" IN ('pending', 'deleting', 'completed')" + }, + "email_destination_cleanups_attempts_check": { + "name": "email_destination_cleanups_attempts_check", + "value": "\"email_destination_cleanups\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_destinations": { + "name": "email_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_source": { + "name": "verification_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cf'" + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by_email_snapshot": { + "name": "verified_by_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destinations_project_id": { + "name": "idx_email_destinations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_cf_destination_id": { + "name": "idx_email_destinations_cf_destination_id", + "columns": [ + { + "expression": "cf_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_verified_by_user_id": { + "name": "idx_email_destinations_verified_by_user_id", + "columns": [ + { + "expression": "verified_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_by_email_snapshot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_destinations_project_id_projects_id_fk": { + "name": "email_destinations_project_id_projects_id_fk", + "tableFrom": "email_destinations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_destinations_verified_by_user_id_users_id_fk": { + "name": "email_destinations_verified_by_user_id_users_id_fk", + "tableFrom": "email_destinations", + "tableTo": "users", + "columnsFrom": ["verified_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_destinations_project_address_uq": { + "name": "email_destinations_project_address_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "address"] + } + }, + "policies": {}, + "checkConstraints": { + "email_destinations_status_check": { + "name": "email_destinations_status_check", + "value": "\"email_destinations\".\"status\" IN ('pending', 'verified')" + }, + "email_destinations_verification_source_check": { + "name": "email_destinations_verification_source_check", + "value": "\"email_destinations\".\"verification_source\" IN ('cf', 'platform')" + } + }, + "isRLSEnabled": false + }, + "public.email_domains": { + "name": "email_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credential_kind": { + "name": "credential_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "relay_ciphertext": { + "name": "relay_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_iv": { + "name": "relay_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_key_version": { + "name": "relay_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_worker_name": { + "name": "relay_worker_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relay_version": { + "name": "relay_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_on": { + "name": "token_expires_on", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_domains_project_id": { + "name": "idx_email_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_zone_uq": { + "name": "email_domains_zone_uq", + "columns": [ + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"zone_id\" IS NOT NULL AND \"email_domains\".\"status\" IN ('active', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_relay_uq": { + "name": "email_domains_relay_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relay_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"relay_worker_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domains_project_id_projects_id_fk": { + "name": "email_domains_project_id_projects_id_fk", + "tableFrom": "email_domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_domains_domain_uq": { + "name": "email_domains_domain_uq", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": { + "email_domains_mode_check": { + "name": "email_domains_mode_check", + "value": "\"email_domains\".\"mode\" IN ('apex', 'subdomain')" + }, + "email_domains_status_check": { + "name": "email_domains_status_check", + "value": "\"email_domains\".\"status\" IN ('pending', 'active', 'token_revoked', 'token_expired', 'failed')" + }, + "email_domains_credential_kind_check": { + "name": "email_domains_credential_kind_check", + "value": "\"email_domains\".\"credential_kind\" IN ('oauth_grant', 'api_token', 'platform')" + }, + "email_domains_platform_token_check": { + "name": "email_domains_platform_token_check", + "value": "(\"email_domains\".\"credential_kind\" = 'platform' AND \"email_domains\".\"token_ciphertext\" IS NULL AND \"email_domains\".\"token_iv\" IS NULL AND \"email_domains\".\"token_key_version\" IS NULL) OR (\"email_domains\".\"credential_kind\" <> 'platform' AND \"email_domains\".\"token_ciphertext\" IS NOT NULL AND \"email_domains\".\"token_iv\" IS NOT NULL AND \"email_domains\".\"token_key_version\" IS NOT NULL)" + }, + "email_domains_managed_by_check": { + "name": "email_domains_managed_by_check", + "value": "\"email_domains\".\"managed_by\" IN ('owner', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "build_executor": { + "name": "build_executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'.github/workflows/void-deploy.yml'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_connections_installation_id": { + "name": "idx_github_connections_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_repo_full_name": { + "name": "idx_github_connections_repo_full_name", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_installation_repo_id": { + "name": "idx_github_connections_installation_repo_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_project_id_projects_id_fk": { + "name": "github_connections_project_id_projects_id_fk", + "tableFrom": "github_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_connections_authorized_by_user_id_users_id_fk": { + "name": "github_connections_authorized_by_user_id_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": ["authorized_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_connections_project_id_unique": { + "name": "github_connections_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_members": { + "name": "github_installation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_members_install_user": { + "name": "idx_github_installation_members_install_user", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installation_members_user_id": { + "name": "idx_github_installation_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_members_user_id_users_id_fk": { + "name": "github_installation_members_user_id_users_id_fk", + "tableFrom": "github_installation_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_revocations": { + "name": "github_installation_revocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_revocations_install_account": { + "name": "idx_github_installation_revocations_install_account", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installations_user_id": { + "name": "idx_github_installations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installations_account_id": { + "name": "idx_github_installations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": ["installation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_redeliveries": { + "name": "github_webhook_redeliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redelivered_at": { + "name": "redelivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_webhook_redeliveries_guid": { + "name": "idx_github_webhook_redeliveries_guid", + "columns": [ + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email_delivery_status": { + "name": "email_delivery_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitations_email": { + "name": "idx_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_status": { + "name": "idx_invitations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "invitations_email_delivery_status_check": { + "name": "invitations_email_delivery_status_check", + "value": "\"invitations\".\"email_delivery_status\" IS NULL OR \"invitations\".\"email_delivery_status\" IN ('sent', 'failed', 'unavailable')" + } + }, + "isRLSEnabled": false + }, + "public.migration_renames": { + "name": "migration_renames", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_migration_renames_project_id": { + "name": "idx_migration_renames_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_renames_project_id_projects_id_fk": { + "name": "migration_renames_project_id_projects_id_fk", + "tableFrom": "migration_renames", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_renames_project_id_from_name_to_name_pk": { + "name": "migration_renames_project_id_from_name_to_name_pk", + "columns": ["project_id", "from_name", "to_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_oauth_accounts_provider_id": { + "name": "idx_oauth_accounts_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_oauth_accounts_user_id": { + "name": "idx_oauth_accounts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_nonces": { + "name": "oauth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_events": { + "name": "operator_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_events_created": { + "name": "idx_operator_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_sessions": { + "name": "operator_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_session_id": { + "name": "human_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_sessions_expiry": { + "name": "idx_operator_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_sessions_user_id_users_id_fk": { + "name": "operator_sessions_user_id_users_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operator_sessions_human_session_id_platform_human_sessions_id_fk": { + "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "platform_human_sessions", + "columnsFrom": ["human_session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_attempts": { + "name": "platform_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_auth_attempts_expiry": { + "name": "platform_auth_attempts_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_auth_attempts_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_attempts", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_bootstrap": { + "name": "platform_auth_bootstrap", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "claim_hash": { + "name": "claim_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_identity": { + "name": "pending_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_bootstrap", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_bootstrap_singleton": { + "name": "platform_auth_bootstrap_singleton", + "value": "\"platform_auth_bootstrap\".\"singleton\" = true" + } + }, + "isRLSEnabled": false + }, + "public.platform_auth_settings": { + "name": "platform_auth_settings", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "admission": { + "name": "admission", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "protection": { + "name": "protection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_seconds": { + "name": "session_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2592000 + }, + "sessions_required": { + "name": "sessions_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_settings_singleton": { + "name": "platform_auth_settings_singleton", + "value": "\"platform_auth_settings\".\"singleton\" = true" + }, + "platform_auth_settings_revision": { + "name": "platform_auth_settings_revision", + "value": "\"platform_auth_settings\".\"revision\" > 0" + }, + "platform_auth_settings_session_seconds": { + "name": "platform_auth_settings_session_seconds", + "value": "\"platform_auth_settings\".\"session_seconds\" BETWEEN 300 AND 2592000" + } + }, + "isRLSEnabled": false + }, + "public.platform_company_identities": { + "name": "platform_company_identities", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_company_identity_user": { + "name": "platform_company_identity_user", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_company_identities_user_id_users_id_fk": { + "name": "platform_company_identities_user_id_users_id_fk", + "tableFrom": "platform_company_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_company_identities_issuer_subject_pk": { + "name": "platform_company_identities_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_events": { + "name": "platform_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_name": { + "name": "worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script_name": { + "name": "script_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_login": { + "name": "admin_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_platform_events_batch_id": { + "name": "idx_platform_events_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_platform_events_env_created": { + "name": "idx_platform_events_env_created", + "columns": [ + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_human_sessions": { + "name": "platform_human_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_issuer": { + "name": "access_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_subject": { + "name": "access_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_human_sessions_connection": { + "name": "platform_human_sessions_connection", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_user": { + "name": "platform_human_sessions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_expiry": { + "name": "platform_human_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_human_sessions_user_id_users_id_fk": { + "name": "platform_human_sessions_user_id_users_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_human_sessions_connection_id_platform_login_connections_id_fk": { + "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_human_sessions_audience": { + "name": "platform_human_sessions_audience", + "value": "\"platform_human_sessions\".\"audience\" IN ('api', 'admin')" + }, + "platform_human_sessions_access_identity": { + "name": "platform_human_sessions_access_identity", + "value": "(\"platform_human_sessions\".\"access_issuer\" IS NULL) = (\"platform_human_sessions\".\"access_subject\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_connections": { + "name": "platform_login_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_revision": { + "name": "active_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_login_connections_kind": { + "name": "platform_login_connections_kind", + "value": "\"platform_login_connections\".\"kind\" IN ('github', 'google', 'oidc', 'cloudflare-access')" + }, + "platform_login_connections_revision": { + "name": "platform_login_connections_revision", + "value": "\"platform_login_connections\".\"revision\" > 0 AND \"platform_login_connections\".\"generation\" > 0" + }, + "platform_login_connections_active_revision": { + "name": "platform_login_connections_active_revision", + "value": "\"platform_login_connections\".\"active_revision\" IS NULL OR \"platform_login_connections\".\"active_revision\" BETWEEN 1 AND \"platform_login_connections\".\"revision\"" + }, + "platform_login_connections_enabled": { + "name": "platform_login_connections_enabled", + "value": "NOT \"platform_login_connections\".\"enabled\" OR \"platform_login_connections\".\"active_revision\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_identities": { + "name": "platform_login_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_login_identity_subject": { + "name": "platform_login_identity_subject", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_login_identity_user": { + "name": "platform_login_identity_user", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_identities_user_id_users_id_fk": { + "name": "platform_login_identities_user_id_users_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_identities_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_revisions": { + "name": "platform_login_revisions", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_login_revisions_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_revisions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_login_revisions_connection_id_revision_pk": { + "name": "platform_login_revisions_connection_id_revision_pk", + "columns": ["connection_id", "revision"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_tests": { + "name": "platform_login_tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_login_tests_expiry": { + "name": "platform_login_tests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_tests_user_id_users_id_fk": { + "name": "platform_login_tests_user_id_users_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_tests_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_settings": { + "name": "platform_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_access_events": { + "name": "project_access_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitation_email_snapshot": { + "name": "invitation_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_role": { + "name": "new_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_access_events_project_created": { + "name": "idx_project_access_events_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_access_events_subject_created": { + "name": "idx_project_access_events_subject_created", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_events_project_id_projects_id_fk": { + "name": "project_access_events_project_id_projects_id_fk", + "tableFrom": "project_access_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_events_previous_role_check": { + "name": "project_access_events_previous_role_check", + "value": "\"project_access_events\".\"previous_role\" IS NULL OR \"project_access_events\".\"previous_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_new_role_check": { + "name": "project_access_events_new_role_check", + "value": "\"project_access_events\".\"new_role\" IS NULL OR \"project_access_events\".\"new_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_action_check": { + "name": "project_access_events_action_check", + "value": "\"project_access_events\".\"action\" IN ('invited', 'invitation_resent', 'member_added', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')" + } + }, + "isRLSEnabled": false + }, + "public.project_cleanup_jobs": { + "name": "project_cleanup_jobs", + "schema": "", + "columns": { + "target": { + "name": "target", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_cleanup_jobs_due": { + "name": "project_cleanup_jobs_due", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_database_credentials": { + "name": "project_database_credentials", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_database_credentials_project_id_projects_id_fk": { + "name": "project_database_credentials_project_id_projects_id_fk", + "tableFrom": "project_database_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_deploy_credentials": { + "name": "project_deploy_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_deploy_credentials_project": { + "name": "project_deploy_credentials_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_deploy_credentials_expiry": { + "name": "project_deploy_credentials_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_deploy_credentials_project_id_projects_id_fk": { + "name": "project_deploy_credentials_project_id_projects_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_deploy_credentials_issued_by_users_id_fk": { + "name": "project_deploy_credentials_issued_by_users_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "users", + "columnsFrom": ["issued_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_deploy_credentials_name": { + "name": "project_deploy_credentials_name", + "value": "length(\"project_deploy_credentials\".\"name\") BETWEEN 1 AND 80" + } + }, + "isRLSEnabled": false + }, + "public.project_invitations": { + "name": "project_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_email_snapshot": { + "name": "target_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_invitations_project_status": { + "name": "idx_project_invitations_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_target_status": { + "name": "idx_project_invitations_target_status", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_pending_target": { + "name": "idx_project_invitations_pending_target", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_invitations\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_invitations_project_id_projects_id_fk": { + "name": "project_invitations_project_id_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_target_user_id_users_id_fk": { + "name": "project_invitations_target_user_id_users_id_fk", + "tableFrom": "project_invitations", + "tableTo": "users", + "columnsFrom": ["target_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_invitations_role_check": { + "name": "project_invitations_role_check", + "value": "\"project_invitations\".\"role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_invitations_status_check": { + "name": "project_invitations_status_check", + "value": "\"project_invitations\".\"status\" IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_members_user_project": { + "name": "idx_project_members_user_project", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_members_project_id_user_id_pk": { + "name": "project_members_project_id_user_id_pk", + "columns": ["project_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_members_role_check": { + "name": "project_members_role_check", + "value": "\"project_members\".\"role\" IN ('reader', 'collaborator', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.project_migrations": { + "name": "project_migrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_deployment_id": { + "name": "source_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_migrations_project_id": { + "name": "idx_project_migrations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "applied_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_migrations_project_id_projects_id_fk": { + "name": "project_migrations_project_id_projects_id_fk", + "tableFrom": "project_migrations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_migrations_source_deployment_id_deployments_id_fk": { + "name": "project_migrations_source_deployment_id_deployments_id_fk", + "tableFrom": "project_migrations", + "tableTo": "deployments", + "columnsFrom": ["source_deployment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_migrations_project_id_name_pk": { + "name": "project_migrations_project_id_name_pk", + "columns": ["project_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_ownership_periods": { + "name": "project_ownership_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "changed_by_user_id": { + "name": "changed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_ownership_periods_owner_time": { + "name": "idx_project_ownership_periods_owner_time", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_project_time": { + "name": "idx_project_ownership_periods_project_time", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_open": { + "name": "idx_project_ownership_periods_open", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_ownership_periods\".\"ends_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_ownership_periods_project_id_projects_id_fk": { + "name": "project_ownership_periods_project_id_projects_id_fk", + "tableFrom": "project_ownership_periods", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_ownership_periods_time_check": { + "name": "project_ownership_periods_time_check", + "value": "\"project_ownership_periods\".\"ends_at\" IS NULL OR \"project_ownership_periods\".\"ends_at\" >= \"project_ownership_periods\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.project_secrets": { + "name": "project_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_reason": { + "name": "managed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_secrets_project_id": { + "name": "idx_project_secrets_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_secrets_project_id_projects_id_fk": { + "name": "project_secrets_project_id_projects_id_fk", + "tableFrom": "project_secrets", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_secrets_project_id_name_unique": { + "name": "project_secrets_project_id_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_zero_trust": { + "name": "project_zero_trust", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "desired_protected": { + "name": "desired_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "effective_protected": { + "name": "effective_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "bypass_application_id": { + "name": "bypass_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bypass_application_name": { + "name": "bypass_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_id": { + "name": "protected_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_name": { + "name": "protected_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_audience": { + "name": "protected_application_audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_cursor": { + "name": "routing_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciled_operation": { + "name": "reconciled_operation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_zero_trust_status": { + "name": "idx_project_zero_trust_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_zero_trust_project_id_projects_id_fk": { + "name": "project_zero_trust_project_id_projects_id_fk", + "tableFrom": "project_zero_trust", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_zero_trust_status_check": { + "name": "project_zero_trust_status_check", + "value": "\"project_zero_trust\".\"status\" IN ('ready', 'reconciling', 'error')" + }, + "project_zero_trust_ready_check": { + "name": "project_zero_trust_ready_check", + "value": "\"project_zero_trust\".\"status\" <> 'ready' OR \"project_zero_trust\".\"desired_protected\" = \"project_zero_trust\".\"effective_protected\"" + } + }, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'void'" + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websocket_version": { + "name": "websocket_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "proxy_token_gen": { + "name": "proxy_token_gen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_projects_slug": { + "name": "idx_projects_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_projects_user_id": { + "name": "idx_projects_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_id_slug_unique": { + "name": "projects_user_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_id": { + "name": "cf_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_name": { + "name": "binding_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_project_id_type_binding_name_unique": { + "name": "resources_project_id_type_binding_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "type", "binding_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_drain_checkpoints": { + "name": "sandbox_drain_checkpoints", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_cycle_start": { + "name": "billing_cycle_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_provider_id": { + "name": "idx_users_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_cache_outbox": { + "name": "email_cache_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_cache_outbox_version_uq": { + "name": "email_cache_outbox_version_uq", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_cache_outbox_pending_idx": { + "name": "email_cache_outbox_pending_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_credentials": { + "name": "email_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_on": { + "name": "expires_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_credentials_scope_idx": { + "name": "email_credentials_scope_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_credentials_material_check": { + "name": "email_credentials_material_check", + "value": "(\"email_credentials\".\"kind\" = 'platform' AND \"email_credentials\".\"ciphertext\" IS NULL AND \"email_credentials\".\"iv\" IS NULL AND \"email_credentials\".\"key_version\" IS NULL) OR (\"email_credentials\".\"kind\" = 'api_token' AND \"email_credentials\".\"ciphertext\" IS NOT NULL AND \"email_credentials\".\"iv\" IS NOT NULL AND \"email_credentials\".\"key_version\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_domain_routes": { + "name": "email_domain_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addressing": { + "name": "addressing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "desired_state": { + "name": "desired_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outbound_reason": { + "name": "outbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_observed_at": { + "name": "outbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_domain_routes_domain_uq": { + "name": "email_domain_routes_domain_uq", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_connection_idx": { + "name": "email_domain_routes_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_project_idx": { + "name": "email_domain_routes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domain_routes_connection_id_email_zone_connections_id_fk": { + "name": "email_domain_routes_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_domain_routes", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_domain_routes_assignment_check": { + "name": "email_domain_routes_assignment_check", + "value": "(\"email_domain_routes\".\"addressing\" = 'custom' AND \"email_domain_routes\".\"project_id\" IS NOT NULL) OR (\"email_domain_routes\".\"addressing\" = 'shared' AND \"email_domain_routes\".\"project_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_operation_steps": { + "name": "email_operation_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_identity": { + "name": "resource_identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_operation_steps_order_uq": { + "name": "email_operation_steps_order_uq", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operation_steps_operation_id_email_operations_id_fk": { + "name": "email_operation_steps_operation_id_email_operations_id_fk", + "tableFrom": "email_operation_steps", + "tableTo": "email_operations", + "columnsFrom": ["operation_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_operations": { + "name": "email_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_generation": { + "name": "route_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_operations_key_uq": { + "name": "email_operations_key_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_work_idx": { + "name": "email_operations_work_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_connection_idx": { + "name": "email_operations_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operations_connection_id_email_zone_connections_id_fk": { + "name": "email_operations_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_operations_route_id_email_domain_routes_id_fk": { + "name": "email_operations_route_id_email_domain_routes_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_domain_routes", + "columnsFrom": ["route_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_shared_recipients": { + "name": "email_shared_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_started_at": { + "name": "create_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removing": { + "name": "removing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_shared_recipients_project_address_uq": { + "name": "email_shared_recipients_project_address_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_rule_name_uq": { + "name": "email_shared_recipients_rule_name_uq", + "columns": [ + { + "expression": "rule_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_shared_recipients_reconcile_idx": { + "name": "email_shared_recipients_reconcile_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_zone_connections": { + "name": "email_zone_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingress_worker_name": { + "name": "ingress_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingress_version": { + "name": "ingress_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "active_secret_generation": { + "name": "active_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "staged_secret_generation": { + "name": "staged_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staged_secret_ciphertext": { + "name": "staged_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_iv": { + "name": "staged_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_key_version": { + "name": "staged_secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_hash": { + "name": "previous_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "management_status": { + "name": "management_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'probe_failed'" + }, + "management_reason": { + "name": "management_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "management_observed_at": { + "name": "management_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_zone_connections_zone_uq": { + "name": "email_zone_connections_zone_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_ingress_uq": { + "name": "email_zone_connections_ingress_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingress_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_manager_idx": { + "name": "email_zone_connections_manager_idx", + "columns": [ + { + "expression": "manager_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_zone_connections_manager_check": { + "name": "email_zone_connections_manager_check", + "value": "(\"email_zone_connections\".\"managed_by\" = 'owner' AND \"email_zone_connections\".\"manager_user_id\" IS NOT NULL) OR (\"email_zone_connections\".\"managed_by\" = 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.email_action_recipients": { + "name": "email_action_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_email_action_recipients_project_reserved": { + "name": "idx_email_action_recipients_project_reserved", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_action_recipients_expiring": { + "name": "idx_email_action_recipients_expiring", + "columns": [ + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"email_action_recipients\".\"state\" = 'reserved'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_action_recipients_action_id_email_actions_id_fk": { + "name": "email_action_recipients_action_id_email_actions_id_fk", + "tableFrom": "email_action_recipients", + "tableTo": "email_actions", + "columnsFrom": ["action_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_action_recipients_action_recipient_uq": { + "name": "email_action_recipients_action_recipient_uq", + "nullsNotDistinct": false, + "columns": ["action_id", "recipient"] + } + }, + "policies": {}, + "checkConstraints": { + "email_action_recipients_state_check": { + "name": "email_action_recipients_state_check", + "value": "\"email_action_recipients\".\"state\" IN ('rejected', 'reserved', 'attempt_started', 'provider_accepted', 'failed', 'outcome_unknown', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.email_actions": { + "name": "email_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_actions_project_created": { + "name": "idx_email_actions_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_actions_receipt_id_email_receipts_id_fk": { + "name": "email_actions_receipt_id_email_receipts_id_fk", + "tableFrom": "email_actions", + "tableTo": "email_receipts", + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_actions_project_key_uq": { + "name": "email_actions_project_key_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "email_actions_kind_check": { + "name": "email_actions_kind_check", + "value": "\"email_actions\".\"kind\" IN ('direct', 'forward', 'reply')" + } + }, + "isRLSEnabled": false + }, + "public.email_monthly_usage": { + "name": "email_monthly_usage", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_attempts": { + "name": "recipient_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inbound_receipts": { + "name": "inbound_receipts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_monthly_usage_project_month_uq": { + "name": "email_monthly_usage_project_month_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "month"] + } + }, + "policies": {}, + "checkConstraints": { + "email_monthly_usage_nonnegative_check": { + "name": "email_monthly_usage_nonnegative_check", + "value": "\"email_monthly_usage\".\"recipient_attempts\" >= 0" + }, + "email_monthly_usage_inbound_nonnegative_check": { + "name": "email_monthly_usage_inbound_nonnegative_check", + "value": "\"email_monthly_usage\".\"inbound_receipts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_project_controls": { + "name": "email_project_controls", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "burst_limit": { + "name": "burst_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "email_project_controls_project_id_projects_id_fk": { + "name": "email_project_controls_project_id_projects_id_fk", + "tableFrom": "email_project_controls", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_receipts": { + "name": "email_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_reject": { + "name": "final_reject", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_receipts_project_created": { + "name": "idx_email_receipts_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unresolved_attempts": { + "name": "email_unresolved_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_email_unresolved_attempts_project": { + "name": "idx_email_unresolved_attempts_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_unresolved_attempts_domain": { + "name": "idx_email_unresolved_attempts_domain", + "columns": [ + { + "expression": "sender_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/platform/packages/api/drizzle/meta/_journal.json b/platform/packages/api/drizzle/meta/_journal.json index d6d34adb0..4e5988aee 100644 --- a/platform/packages/api/drizzle/meta/_journal.json +++ b/platform/packages/api/drizzle/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1790912739624, "tag": "0057_github_authority_constraints", "breakpoints": true + }, + { + "idx": 58, + "version": "7", + "when": 1791311435335, + "tag": "0058_project_proxy_token_gen", + "breakpoints": true } ] } diff --git a/platform/packages/api/src/deploy/recovery.ts b/platform/packages/api/src/deploy/recovery.ts index 746fac866..f98347818 100644 --- a/platform/packages/api/src/deploy/recovery.ts +++ b/platform/packages/api/src/deploy/recovery.ts @@ -29,6 +29,7 @@ import { removeQueueConsumer, getDispatchWorkerProxyToken, } from '../provisioning/cloudflare-api.ts'; +import { proxyTokenCopies } from '../proxy-token-rotation.ts'; import { buildRoutingJson, buildStaticRoutingJson, computeDispatchLimits } from '../routing.ts'; import { type DeployManifest, type Deployment, type Project } from '../types.ts'; import { @@ -329,9 +330,15 @@ export async function replayDeploymentCutover( desiredQueueResources.set(queue.name, resource); } const needsProxyToken = !isStatic && (!!manifest.schedules?.length || desiredQueues.length > 0); - const proxyToken = needsProxyToken - ? await resolveCutoverProxyToken(env, workerName, project.id) - : null; + // The replayed Worker may not hold the issued generation yet, so its copies + // carry the issued token as the fallback. + const proxyTokens = needsProxyToken + ? await proxyTokenCopies( + env.JWT_SECRET, + project, + await resolveCutoverProxyToken(env, workerName, project.id), + ) + : {}; await env.ROUTING.put(project.slug, routingJson); await reconcileProjectDomainRoutes(env, domains, routingJson); @@ -339,7 +346,7 @@ export async function replayDeploymentCutover( await syncCronScheduler(env, project.id, { workerName, schedules: manifest.schedules, - proxyToken: proxyToken!, + ...proxyTokens, }); } else { await clearCronScheduler(env, project.id); @@ -365,7 +372,7 @@ export async function replayDeploymentCutover( } await env.ROUTING.put( `queue:${getProjectQueueName(project.id, queue.name)}`, - JSON.stringify({ workerName, projectId: project.id, queueName: queue.name, proxyToken }), + JSON.stringify({ workerName, projectId: project.id, queueName: queue.name, ...proxyTokens }), ); } diff --git a/platform/packages/api/src/deploy/upload-worker.ts b/platform/packages/api/src/deploy/upload-worker.ts index 084d66e91..334518844 100644 --- a/platform/packages/api/src/deploy/upload-worker.ts +++ b/platform/packages/api/src/deploy/upload-worker.ts @@ -24,7 +24,7 @@ import { provisionSandboxController, sandboxControllerWorkerName, } from '../provisioning/sandbox-controller.ts'; -import { issueProxyToken } from '../proxy-token.ts'; +import { issueProjectProxyToken } from '../proxy-token-rotation.ts'; import { RESERVED_BINDING_NAMES } from '../reserved-bindings.ts'; import { deployments } from '../schema.ts'; import { type DeployManifest, type DeployEvent, type Deployment, type Project } from '../types.ts'; @@ -117,7 +117,11 @@ export async function uploadDeploymentWorker(input: { context.env.VOID_PROXY_SERVICE ?? 'void-proxy', { projectSlug: project.slug, - proxyToken: await issueProxyToken(context.env.ROUTING, context.env.JWT_SECRET, projectId), + proxyToken: await issueProjectProxyToken( + context.get('db'), + context.env.JWT_SECRET, + projectId, + ), emailSenderDomain: context.env.EMAIL_SENDER_DOMAIN, deploymentId: deployment.id, }, diff --git a/platform/packages/api/src/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index 09efa809f..3443f5a9b 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -41,6 +41,7 @@ import { cancelContainerBuildDos } from '../github/cancel-builds.ts'; import { generateId } from '../id.ts'; import { getLegacyProjectWorkerName, getProjectDeploymentWorkerName } from '../naming.ts'; import { requireProjectCapability } from '../project-access.ts'; +import { proxyTokenStateKey } from '../proxy-token.ts'; import { deployments as deploymentsTable, projectCleanupJobs, @@ -457,6 +458,12 @@ async function cleanupBestEffortResources( cleanup.push( cleanupJob(projectId, 'routing-key', `project:${projectId}`, { key: `project:${projectId}` }), ); + // Proxy token accept floor. + cleanup.push( + cleanupJob(projectId, 'routing-key', proxyTokenStateKey(projectId), { + key: proxyTokenStateKey(projectId), + }), + ); // Queue routing KV entries for (const q of resources.filter((r) => r.type === 'queue')) { diff --git a/platform/packages/api/src/proxy-token-rotation.ts b/platform/packages/api/src/proxy-token-rotation.ts new file mode 100644 index 000000000..166302722 --- /dev/null +++ b/platform/packages/api/src/proxy-token-rotation.ts @@ -0,0 +1,63 @@ +import { eq } from 'drizzle-orm'; +import type { Db } from './db.ts'; +import { deriveProxyToken } from './proxy-token.ts'; +import { projects } from './schema.ts'; +import type { Project } from './types.ts'; + +/** + * Proxy token generations per project. + * + * `projects.proxy_token_gen` is the highest generation the platform has issued + * for the project (0 = the legacy token). Deploys bake the token for that + * generation into the Worker, and the proxy sends it. Only a rotation raises + * it; the KV accept floor (`proxy-token:`) never exceeds it. + */ + +export const PROXY_TOKEN_ROTATION_JOB_KIND = 'proxy-token-rotation'; +/** How long a rotation waits for KV copies and old Worker versions to drain. */ +export const PROXY_TOKEN_ROTATION_WAIT_MS = 120_000; + +/** Primary key of a project's rotation row in `project_cleanup_jobs`. */ +export function proxyTokenRotationTarget(projectId: string): string { + return `${PROXY_TOKEN_ROTATION_JOB_KIND}:${projectId}`; +} + +/** The token for the project's issued generation. Generation 0 is the legacy token. */ +export async function issueProjectProxyToken( + db: Db, + jwtSecret: string, + projectId: string, +): Promise { + const [row] = await db + .select({ gen: projects.proxy_token_gen }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + if (!row) { + throw new Error(`project ${projectId} not found`); + } + return deriveProxyToken(jwtSecret, projectId, row.gen); +} + +/** + * Token fields for the queue routing entries and the cron scheduler when they + * point at an existing Worker. A Worker that has not yet received the issued + * generation still holds its own token; the issued one rides along as the + * fallback so the relay keeps working once that Worker is updated. + */ +export async function proxyTokenCopies( + jwtSecret: string, + project: Pick, + workerToken: string | undefined, +): Promise> { + if (workerToken === undefined) { + return {}; + } + if (project.proxy_token_gen < 1) { + return { proxyToken: workerToken }; + } + const issued = await deriveProxyToken(jwtSecret, project.id, project.proxy_token_gen); + return workerToken === issued + ? { proxyToken: workerToken } + : { proxyToken: workerToken, proxyTokenFallback: issued }; +} diff --git a/platform/packages/api/src/proxy-token.ts b/platform/packages/api/src/proxy-token.ts index dd31d528c..b8297e15f 100644 --- a/platform/packages/api/src/proxy-token.ts +++ b/platform/packages/api/src/proxy-token.ts @@ -273,19 +273,6 @@ export async function verifyProxyToken( return true; } -/** - * Returns the token for the current floor (floor 0 is the legacy token). - * Stopgap until the issued generation is stored with the project. - */ -export async function issueProxyToken( - routing: Pick, - jwtSecret: string, - projectId: string, -): Promise { - const state = await readProxyTokenState(routing, projectId); - return deriveProxyToken(jwtSecret, projectId, state.gen); -} - // --- Isolate cache --- export type ProxyTokenStateCache = Readonly<{ diff --git a/platform/packages/api/src/routes/internal-resolve-worker.ts b/platform/packages/api/src/routes/internal-resolve-worker.ts index 5e00cbaa5..c87d988db 100644 --- a/platform/packages/api/src/routes/internal-resolve-worker.ts +++ b/platform/packages/api/src/routes/internal-resolve-worker.ts @@ -12,7 +12,8 @@ const app = new Hono(); * Authenticated by the proxy's own credential (see requireProxyInternalAuth). * * POST /internal/resolve-worker/:projectId - * Returns: { "workerName": "user--", "deploymentId": "..." } + * Returns: { "workerName": "user--", "deploymentId": "...", + * "proxyTokenGen": } * or 409 when the active deployment is static-only. */ app.post('/:projectId', async (context) => { @@ -47,6 +48,7 @@ app.post('/:projectId', async (context) => { return context.json({ workerName: getProjectDeploymentWorkerName(project.slug, deployment.id), deploymentId: deployment.id, + proxyTokenGen: project.proxy_token_gen, }); }); diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 193172644..5da8d95dd 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -87,6 +87,7 @@ import { isAlreadyExistsError as isDestinationAlreadyExistsError, } from '../provisioning/email-routing.ts'; import { isManagedSandbox, verifySandboxController } from '../provisioning/sandbox-controller.ts'; +import { proxyTokenCopies } from '../proxy-token-rotation.ts'; import { EMAIL_RESERVED_SLUGS } from '../reserved-slugs.ts'; import { buildRoutingJson, @@ -1516,10 +1517,17 @@ const app = new Hono() // /__void/scheduled and /__void/queue handlers both validate // against the SAME __VOID_PROXY_TOKEN binding, so one lookup // is authoritative for both. + // The target may not hold the issued generation yet (a rotation + // is still updating it), so its copies carry the issued token as + // the fallback. const needsProxyToken = !!manifest?.schedules?.length || !!manifest?.queues?.length; - const targetProxyToken = needsProxyToken - ? await resolveRollbackProxyToken(context.env, workerName, projectId) - : null; + const targetProxyTokens = needsProxyToken + ? await proxyTokenCopies( + context.env.JWT_SECRET, + lockedProject, + await resolveRollbackProxyToken(context.env, workerName, projectId), + ) + : {}; // Sync cron scheduler to point at the rollback target. // @@ -1536,7 +1544,7 @@ const app = new Hono() await syncCronScheduler(context.env, projectId, { workerName, schedules: manifest.schedules, - proxyToken: targetProxyToken!, + ...targetProxyTokens, }); } else { await clearCronScheduler(context.env, projectId); @@ -1669,7 +1677,7 @@ const app = new Hono() workerName, projectId, queueName: q.name, - proxyToken: targetProxyToken!, + ...targetProxyTokens, }), ); } diff --git a/platform/packages/api/src/schema.ts b/platform/packages/api/src/schema.ts index 1741a4eb4..7a444397c 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -306,6 +306,8 @@ export const projects = pgTable( type: text('mode').notNull().default('void').$type<'void' | 'spa' | 'static' | 'framework'>(), active_deployment_id: text('active_deployment_id'), websocket_version: integer('websocket_version').notNull().default(0), + // Highest proxy token generation issued for this project. 0 = legacy token. + proxy_token_gen: integer('proxy_token_gen').notNull().default(0), status: text('status').notNull().default('active').$type<'active' | 'deleting'>(), created_at: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), }, @@ -636,6 +638,7 @@ export const projectCleanupJobs = pgTable( | 'routing-key' | 'cron' | 'custom-hostname' + | 'proxy-token-rotation' >(), payload: jsonb('payload').notNull().$type>(), attempts: integer('attempts').notNull().default(0), diff --git a/platform/packages/api/test/integration/api-delete.test.ts b/platform/packages/api/test/integration/api-delete.test.ts index 0f70d410d..604053d0d 100644 --- a/platform/packages/api/test/integration/api-delete.test.ts +++ b/platform/packages/api/test/integration/api-delete.test.ts @@ -610,6 +610,49 @@ describe('DELETE /projects/:id', () => { expect(await env.ROUTING.get(project.slug)).toBe(JSON.stringify({ projectId: replacement.id })); }); + it('deletes the proxy token floor and retries a failed delete from the cleanup queue', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'proxy-token-floor'); + const floorKey = `proxy-token:${project.id}`; + await env.ROUTING.put(floorKey, JSON.stringify({ gen: 2 })); + mockCfApi(); + const deleteRoute = env.ROUTING.delete.bind(env.ROUTING); + const routeDelete = vi.spyOn(env.ROUTING, 'delete').mockImplementation(async (key) => { + if (key === floorKey) { + throw new Error('temporary KV failure'); + } + return deleteRoute(key); + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${user.jwt}` }, + }), + env, + ); + expect(res.status).toBe(200); + expect(await db.getProject(env.db, project.id)).toBeNull(); + const pending = await env.db.execute<{ kind: string; payload: unknown }>(sql` + SELECT kind, payload FROM project_cleanup_jobs + WHERE target = ${`routing-key:${project.id}:${floorKey}`} + `); + expect(pending.rows).toEqual([{ kind: 'routing-key', payload: { key: floorKey } }]); + + routeDelete.mockRestore(); + await scheduled( + { cron: FAST_CRON, scheduledTime: Date.now() } as ScheduledController, + env as unknown as Parameters[1], + createMockExecutionContext(), + ); + expect(await env.ROUTING.get(floorKey)).toBeNull(); + const finished = await env.db.execute(sql` + SELECT target FROM project_cleanup_jobs + WHERE target = ${`routing-key:${project.id}:${floorKey}`} + `); + expect(finished.rows).toEqual([]); + }); + it('deletes Zero Trust applications only after retryable cleanup succeeds', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'zero-trust-teardown'); diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 8b8b0966c..4a3d57c2b 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -28,7 +28,7 @@ import app from '../../src/index.ts'; import { writePlanCatalog } from '../../src/plans.ts'; import * as projectAccess from '../../src/project-access.ts'; import * as projectIngress from '../../src/project-ingress.ts'; -import { deriveProxyToken, proxyTokenStateKey } from '../../src/proxy-token.ts'; +import { deriveProxyToken } from '../../src/proxy-token.ts'; import { validateManifestShape } from '../../src/routes/deploy.ts'; import { builds, @@ -2006,10 +2006,10 @@ describe('POST /projects/:id/deploy', () => { }); }); - it('issues the proxy token for the project accept floor', async () => { + it('issues the proxy token for the project generation', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'queue-kv-floor-app'); - await env.ROUTING.put(proxyTokenStateKey(project.id), JSON.stringify({ gen: 1 })); + await env.db.update(projects).set({ proxy_token_gen: 1 }).where(eq(projects.id, project.id)); const fetchMock = mockCfApi(); const res = await app.fetch( diff --git a/platform/packages/api/test/integration/api-internal-project-token.test.ts b/platform/packages/api/test/integration/api-internal-project-token.test.ts index 4b0160fc5..c09f45b8f 100644 --- a/platform/packages/api/test/integration/api-internal-project-token.test.ts +++ b/platform/packages/api/test/integration/api-internal-project-token.test.ts @@ -1,3 +1,4 @@ +import { eq } from 'drizzle-orm'; import { afterAll, afterEach, @@ -17,6 +18,7 @@ import { deriveProxyToken, type ProxyInternalRoute, } from '../../src/proxy-token.ts'; +import { projects } from '../../src/schema.ts'; import { clearTables, createTestDeployment, @@ -167,6 +169,7 @@ describe('per-project token checks', () => { expect(await worker.json()).toEqual({ workerName: `user-${project.slug}-${deployment.id}`, deploymentId: deployment.id, + proxyTokenGen: 0, }); const prerender = await internalRequest('verify-prerender-build', project.id, token, { @@ -286,6 +289,7 @@ describe('proxy credential on the metadata routes', () => { 'resolve-worker': { workerName: `user-${project.slug}-${deployment.id}`, deploymentId: deployment.id, + proxyTokenGen: 0, }, 'resolve-resource': { cf_id: 'cf-kv-id' }, 'verify-prerender-build': { ok: true }, @@ -319,6 +323,25 @@ describe('proxy credential on the metadata routes', () => { }, ); + it('reports the issued proxy token generation from /internal/resolve-worker', async () => { + const { project } = await liveProject(); + await env.db.update(projects).set({ proxy_token_gen: 3 }).where(eq(projects.id, project.id)); + const response = await internalRequest( + 'resolve-worker', + project.id, + { + 'x-void-proxy-internal': await deriveProxyInternalAuth( + JWT_SECRET, + 'resolve-worker', + project.id, + ), + }, + {}, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ proxyTokenGen: 3 }); + }); + it.each(routes)( 'rejects a proxy credential made for another route at /internal/%s', async (route) => { diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index 0f34016b5..9e8a2a976 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -18,7 +18,7 @@ import { import * as db from '../../src/db.ts'; import app from '../../src/index.ts'; import * as projectAccess from '../../src/project-access.ts'; -import { deriveProxyInternalAuth } from '../../src/proxy-token.ts'; +import { deriveProxyInternalAuth, deriveProxyToken } from '../../src/proxy-token.ts'; import { users } from '../../src/schema.ts'; import { deployments, @@ -3138,6 +3138,90 @@ describe('POST /projects/:id/rollback', () => { proxyToken: 'token-baked-into-d1', }); }); + + it.each([ + { issued: 1, held: 0, fallback: 1 }, + { issued: 1, held: 1, fallback: null }, + { issued: 0, held: 0, fallback: null }, + ])( + 'rollback to a Worker on generation $held while generation $issued is issued writes fallback $fallback', + async ({ issued, held, fallback }) => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'roll-token-gen'); + await env.db + .update(projects) + .set({ proxy_token_gen: issued }) + .where(eq(projects.id, project.id)); + const queueRoutingKey = `queue:${project.id.replaceAll('_', '-')}-emails`; + await env.db.insert(resources).values({ + id: 'res_roll_token_gen', + project_id: project.id, + type: 'queue', + cf_id: '25832cb0ce3d4f60b1e538911a4629f4', + binding_name: 'emails', + }); + const manifest = JSON.stringify({ + version: 3, + bindings: {}, + schedules: [{ cron: '*/5 * * * *', filePath: 'crons/tick.ts' }], + queues: [{ name: 'emails', bindingName: 'QUEUE_EMAILS' }], + }); + const d1 = await createDeployment(env.db, project.id, manifest); + await updateDeploymentStatus(env.db, d1.id, 'live'); + await markDeploymentRetained(env.db, d1.id, true); + const d2 = await createDeployment(env.db, project.id, manifest); + await updateDeploymentStatus(env.db, d2.id, 'live'); + await markDeploymentRetained(env.db, d2.id, true); + await setActiveDeployment(env.db, project.id, d2.id); + + const heldToken = await deriveProxyToken(env.JWT_SECRET, project.id, held); + mockCfApi({ + listWorkerBindings: () => + cfSuccess([{ type: 'plain_text', name: '__VOID_PROXY_TOKEN', text: heldToken }]), + }); + const syncBodies: Array = []; + const originalGet = env.CRON_SCHEDULER.get.bind(env.CRON_SCHEDULER); + vi.spyOn(env.CRON_SCHEDULER, 'get').mockImplementation((id) => { + const stub = originalGet(id); + return { + ...stub, + fetch: async (req: Request) => { + if (new URL(req.url).pathname === '/sync') { + syncBodies.push(await req.text()); + } + return new Response('{}', { status: 200 }); + }, + } as unknown as DurableObjectStub; + }); + + const res = await app.fetch( + new Request(`http://localhost/projects/${project.id}/rollback`, { + method: 'POST', + headers: { Authorization: `Bearer ${user.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ deploymentId: d1.id }), + }), + env, + ); + expect(res.status).toBe(200); + + const expected = { + proxyToken: heldToken, + ...(fallback === null + ? {} + : { proxyTokenFallback: await deriveProxyToken(env.JWT_SECRET, project.id, fallback) }), + }; + expect(await env.ROUTING.get(queueRoutingKey, 'json')).toEqual({ + workerName: `user-roll-token-gen-${d1.id}`, + projectId: project.id, + queueName: 'emails', + ...expected, + }); + expect(syncBodies).toHaveLength(1); + const sync = JSON.parse(syncBodies[0]!) as Record; + expect(sync.proxyToken).toBe(expected.proxyToken); + expect(sync.proxyTokenFallback).toBe(expected.proxyTokenFallback); + }, + ); }); describe('GET /projects/:id/logs', () => { diff --git a/platform/packages/api/test/integration/api-resolve-worker.test.ts b/platform/packages/api/test/integration/api-resolve-worker.test.ts index 004a5caa2..1d234babf 100644 --- a/platform/packages/api/test/integration/api-resolve-worker.test.ts +++ b/platform/packages/api/test/integration/api-resolve-worker.test.ts @@ -51,6 +51,7 @@ describe('POST /internal/resolve-worker/:projectId', () => { expect(await current.json()).toEqual({ workerName: `user-${project.slug}-${newer.id}`, deploymentId: newer.id, + proxyTokenGen: 0, }); await setActiveDeployment(env.db, project.id, rollbackTarget.id); @@ -59,6 +60,7 @@ describe('POST /internal/resolve-worker/:projectId', () => { expect(await rolledBack.json()).toEqual({ workerName: `user-${project.slug}-${rollbackTarget.id}`, deploymentId: rollbackTarget.id, + proxyTokenGen: 0, }); }); diff --git a/platform/packages/api/test/integration/deployment-recovery.test.ts b/platform/packages/api/test/integration/deployment-recovery.test.ts index d2dad46f1..96cdb4c3e 100644 --- a/platform/packages/api/test/integration/deployment-recovery.test.ts +++ b/platform/packages/api/test/integration/deployment-recovery.test.ts @@ -1,14 +1,18 @@ import { eq } from 'drizzle-orm'; import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from 'vite-plus/test'; import { getDeployment, getProject, type Db } from '../../src/db.ts'; +import { replayDeploymentCutover } from '../../src/deploy/recovery.ts'; import { settleExpiredDeployment } from '../../src/deployment-recovery.ts'; import type { ApiEnv } from '../../src/env.ts'; +import { deriveProxyToken } from '../../src/proxy-token.ts'; import { deployments, domains, projectMigrations, projects, resources } from '../../src/schema.ts'; import { clearTables, createTestDeployment, createTestProject, createTestUser, + cfSuccess, + mockCfApi, setupEnv, teardownEnv, type TestEnv, @@ -289,3 +293,85 @@ test('preserves retained historical deployment artifacts while terminalizing its expect(fetch).not.toHaveBeenCalled(); expect(await env.ROUTING.get(`manifest:${candidate.id}`)).toBe('{}'); }); + +test.each([ + { issued: 1, held: 0, fallback: 1 }, + { issued: 1, held: 1, fallback: null }, + { issued: 0, held: 0, fallback: null }, +])( + 'cutover replay to a Worker on generation $held while generation $issued is issued writes fallback $fallback', + async ({ issued, held, fallback }) => { + await env.db + .update(projects) + .set({ proxy_token_gen: issued }) + .where(eq(projects.id, project.id)); + const deployment = await createTestDeployment(env.db, project.id); + await env.db + .update(deployments) + .set({ + manifest: JSON.stringify({ + version: 3, + type: 'void', + bindings: {}, + schedules: [{ cron: '*/5 * * * *', filePath: 'crons/tick.ts' }], + queues: [{ name: 'emails', bindingName: 'QUEUE_EMAILS' }], + }), + }) + .where(eq(deployments.id, deployment.id)); + await env.db.insert(resources).values({ + id: 'resource-replay-queue', + project_id: project.id, + type: 'queue', + cf_id: '25832cb0ce3d4f60b1e538911a4629f4', + binding_name: 'emails', + }); + const heldToken = await deriveProxyToken(env.JWT_SECRET, project.id, held); + mockCfApi({ + listWorkerBindings: () => + cfSuccess([{ type: 'plain_text', name: '__VOID_PROXY_TOKEN', text: heldToken }]), + }); + const syncBodies: Array = []; + const originalGet = env.CRON_SCHEDULER.get.bind(env.CRON_SCHEDULER); + vi.spyOn(env.CRON_SCHEDULER, 'get').mockImplementation((id) => { + const stub = originalGet(id); + return { + ...stub, + fetch: async (request: Request) => { + if (new URL(request.url).pathname === '/sync') { + syncBodies.push(await request.text()); + } + return new Response('{}', { status: 200 }); + }, + } as unknown as DurableObjectStub; + }); + + const lockedProject = (await getProject(env.db, project.id))!; + await replayDeploymentCutover( + env as unknown as ApiEnv['Bindings'], + env.db, + lockedProject, + (await getDeployment(env.db, deployment.id))!, + lockedProject.type, + { restoreCommitted: true }, + ); + + const expected = { + proxyToken: heldToken, + ...(fallback === null + ? {} + : { proxyTokenFallback: await deriveProxyToken(env.JWT_SECRET, project.id, fallback) }), + }; + expect( + await env.ROUTING.get(`queue:${project.id.replaceAll('_', '-')}-emails`, 'json'), + ).toEqual({ + workerName: `user-${project.slug}-${deployment.id}`, + projectId: project.id, + queueName: 'emails', + ...expected, + }); + expect(syncBodies).toHaveLength(1); + const sync = JSON.parse(syncBodies[0]!) as Record; + expect(sync.proxyToken).toBe(expected.proxyToken); + expect(sync.proxyTokenFallback).toBe(expected.proxyTokenFallback); + }, +); diff --git a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts index 4dc455b29..1cf12f31f 100644 --- a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts +++ b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts @@ -187,3 +187,36 @@ it('allows upgrading the 0.21.3 through 0.21.9 schema after adding project Zero expect(compatibility.rollbackSafeFrom).toContainEqual({ platformVersion, schemaDigest }); } }); + +// 0.25.0 and 0.26.0 ignore projects.proxy_token_gen, so they can run on the 0058 schema. +it('allows upgrading the 0.25.0 and 0.26.0 schema after adding the proxy token generation', () => { + const root = resolve(import.meta.dirname, '../../drizzle'); + const journal = JSON.parse(readFileSync(resolve(root, 'meta/_journal.json'), 'utf8')) as { + entries: Array<{ tag: string; idx: number; when: number }>; + }; + const compatibility = JSON.parse(readFileSync(resolve(root, 'compatibility.json'), 'utf8')) as { + lineage: string; + rollbackSafeFrom: Array<{ platformVersion: string; schemaDigest: string }>; + }; + expect(journal.entries.at(-1)?.tag).toBe('0058_project_proxy_token_gen'); + const previous = journal.entries.filter((entry) => entry.idx < 58); + expect(previous.at(-1)?.tag).toBe('0057_github_authority_constraints'); + const schemaDigest = platformDatabaseSchemaDigest( + compatibility.lineage, + previous.map((entry) => ({ + index: entry.idx, + tag: entry.tag, + timestampMillis: entry.when, + sha256: createHash('sha256') + .update(readFileSync(resolve(root, `${entry.tag}.sql`))) + .digest('hex'), + })), + ); + expect(schemaDigest).toBe('7581930465664b7c578518bea54f36a83d20fcc47d9528183419033930d7ff4a'); + expect( + compatibility.rollbackSafeFrom.filter((entry) => entry.schemaDigest === schemaDigest), + ).toEqual([ + { platformVersion: '0.25.0', schemaDigest }, + { platformVersion: '0.26.0', schemaDigest }, + ]); +}); diff --git a/platform/packages/api/test/unit/proxy-token.test.ts b/platform/packages/api/test/unit/proxy-token.test.ts index 4d4af8959..ddff49e91 100644 --- a/platform/packages/api/test/unit/proxy-token.test.ts +++ b/platform/packages/api/test/unit/proxy-token.test.ts @@ -7,7 +7,6 @@ import { deriveProxyInternalAuth, deriveProxyToken, isAcceptedGeneration, - issueProxyToken, LEGACY_PROXY_TOKEN_STATE, parseProxyTokenState, PROXY_INTERNAL_AUTH_HEADER, @@ -156,12 +155,12 @@ describe('parseProxyTokenState', () => { describe('state table', () => { it.each([ - { raw: null, accepted: [0, 1, 5], rejected: [], issue: 0 }, - { raw: '{"gen":0}', accepted: [0, 1, 5], rejected: [], issue: 0 }, - { raw: '{"gen":1}', accepted: [1, 2], rejected: [0], issue: 1 }, - { raw: '{"gen":2}', accepted: [2, 3], rejected: [0, 1], issue: 2 }, - { raw: '{"gen":3}', accepted: [3, 4], rejected: [0, 1, 2], issue: 3 }, - ])('floor $raw', async ({ raw, accepted, rejected, issue }) => { + { raw: null, accepted: [0, 1, 5], rejected: [] }, + { raw: '{"gen":0}', accepted: [0, 1, 5], rejected: [] }, + { raw: '{"gen":1}', accepted: [1, 2], rejected: [0] }, + { raw: '{"gen":2}', accepted: [2, 3], rejected: [0, 1] }, + { raw: '{"gen":3}', accepted: [3, 4], rejected: [0, 1, 2] }, + ])('floor $raw', async ({ raw, accepted, rejected }) => { const { routing } = kvStub(raw === null ? {} : { [proxyTokenStateKey(PROJECT)]: raw }); const state = await readProxyTokenState(routing, PROJECT); for (const gen of accepted) { @@ -172,15 +171,11 @@ describe('state table', () => { expect(isAcceptedGeneration(state, gen)).toBe(false); expect(await verify(await deriveProxyToken(SECRET, PROJECT, gen), state).result).toBe(false); } - expect(await issueProxyToken(routing, SECRET, PROJECT)).toBe( - await deriveProxyToken(SECRET, PROJECT, issue), - ); }); it('fails closed on a malformed floor', async () => { const { routing } = kvStub({ [proxyTokenStateKey(PROJECT)]: '{"gen":"x"}' }); await expect(readProxyTokenState(routing, PROJECT)).rejects.toThrow(ProxyTokenStateError); - await expect(issueProxyToken(routing, SECRET, PROJECT)).rejects.toThrow(ProxyTokenStateError); }); }); From 0fc881cfb4de62f474d147be642d43f028b26b59 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 02:39:29 +0800 Subject: [PATCH 02/29] Add Cloudflare helpers for proxy token rotation Read a tenant script's bindings and replace its __VOID_PROXY_TOKEN through a settings PATCH that inherits every other binding, so a rotation needs no code upload. The PATCH goes out as one multipart "settings" part. Each call counts against the rotation run's budget and fails before fetch once calls or time run out. A 429 throws with its Retry-After seconds instead of sleeping, and a missing script (404 or code 10007) reads as null. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/proxy-token-rotation-cloudflare.ts | 162 ++++++++++++++++ .../packages/api/src/proxy-token-rotation.ts | 3 + .../proxy-token-rotation-cloudflare.test.ts | 178 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 platform/packages/api/src/proxy-token-rotation-cloudflare.ts create mode 100644 platform/packages/api/test/unit/proxy-token-rotation-cloudflare.test.ts diff --git a/platform/packages/api/src/proxy-token-rotation-cloudflare.ts b/platform/packages/api/src/proxy-token-rotation-cloudflare.ts new file mode 100644 index 000000000..e5f5785e1 --- /dev/null +++ b/platform/packages/api/src/proxy-token-rotation-cloudflare.ts @@ -0,0 +1,162 @@ +import { CF_API_BASE, CloudflareApiError } from './provisioning/cloudflare-api.ts'; +import type { RotationBudget } from './proxy-token-rotation.ts'; + +/** + * Cloudflare calls a proxy token rotation makes against tenant Workers: read a + * script's bindings, and replace its `__VOID_PROXY_TOKEN` through a settings + * PATCH that inherits every other binding (no code upload). + * + * Every call is counted against the run's budget and never retried or slept + * on here; the rotation decides what to do with a rate limit or an error. + */ + +const PROXY_TOKEN_BINDING = '__VOID_PROXY_TOKEN'; +const REQUEST_TIMEOUT_MS = 60_000; +const DEFAULT_RETRY_AFTER_SECONDS = 60; +/** Cloudflare's "script not found" error code. */ +const SCRIPT_NOT_FOUND_CODE = 10007; + +export class CloudflareRateLimitError extends Error { + readonly retryAfterSeconds: number; + constructor(retryAfterSeconds: number) { + super(`Cloudflare API rate limited; retry after ${retryAfterSeconds} seconds`); + this.name = 'CloudflareRateLimitError'; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +/** Thrown before a call when the run has no Cloudflare calls or time left. */ +export class RotationBudgetExhaustedError extends Error { + constructor() { + super('proxy token rotation budget exhausted'); + this.name = 'RotationBudgetExhaustedError'; + } +} + +export type ScriptBinding = Readonly<{ name: string; type: string; text?: string }>; + +/** + * Settings PATCH bindings that keep every binding as it is (`inherit`, in the + * original order) and set the proxy token. A binding left out of the list + * would be removed, so every name is listed. + */ +export function buildProxyTokenBindingsPatch( + existing: ReadonlyArray, + token: string, +): ReadonlyArray> { + if (!existing.some((binding) => binding.name === PROXY_TOKEN_BINDING)) { + throw new Error(`script has no ${PROXY_TOKEN_BINDING} binding`); + } + return existing.map((binding): Record => + binding.name === PROXY_TOKEN_BINDING + ? { type: 'plain_text', name: PROXY_TOKEN_BINDING, text: token } + : { type: 'inherit', name: binding.name }, + ); +} + +export type RotationCloudflareClient = Readonly<{ + /** The script's bindings, or null when the script does not exist. */ + getScriptBindings(scriptName: string): Promise | null>; + /** The bindings in the PATCH result, or null when the result has none. */ + patchScriptBindings( + scriptName: string, + bindings: ReadonlyArray>, + ): Promise | null>; +}>; + +type CfEnvelope = Readonly<{ + success: boolean; + errors?: ReadonlyArray<{ code: number; message: string }>; + result: T; +}>; + +function parseRetryAfter(value: string | null): number { + const seconds = Number(value?.trim() || Number.NaN); + return Number.isFinite(seconds) && seconds >= 0 + ? Math.ceil(seconds) + : DEFAULT_RETRY_AFTER_SECONDS; +} + +export function createRotationCloudflareClient( + input: Readonly<{ + accountId: string; + apiToken: string; + dispatchNamespace: string; + budget: RotationBudget; + fetch?: typeof fetch; + }>, +): RotationCloudflareClient { + const send = input.fetch ?? ((url, init) => fetch(url, init)); + const scriptsUrl = `${CF_API_BASE}/${input.accountId}/workers/dispatch/namespaces/${input.dispatchNamespace}/scripts`; + + async function request(method: string, path: string, body?: BodyInit): Promise { + const { budget } = input; + if (budget.cfCallsRemaining <= 0 || Date.now() >= budget.deadline) { + throw new RotationBudgetExhaustedError(); + } + budget.cfCallsRemaining -= 1; + const headers: Record = { Authorization: `Bearer ${input.apiToken}` }; + if (body !== undefined && !(body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + const response = await send(`${scriptsUrl}${path}`, { + method, + headers, + body, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (response.status === 429) { + throw new CloudflareRateLimitError(parseRetryAfter(response.headers.get('Retry-After'))); + } + let json: CfEnvelope; + try { + json = (await response.json()) as CfEnvelope; + } catch { + throw new CloudflareApiError( + `Cloudflare API error: HTTP ${response.status} ${method} ${path}`, + response.status, + ); + } + if (!json.success) { + const errors = json.errors ?? []; + throw new CloudflareApiError( + `Cloudflare API error: ${errors.map((error) => error.message).join(', ')}`, + response.status, + errors.map((error) => error.code), + ); + } + return json.result; + } + + return { + async getScriptBindings(scriptName) { + try { + return await request>( + 'GET', + `/${encodeURIComponent(scriptName)}/bindings`, + ); + } catch (error) { + if ( + error instanceof CloudflareApiError && + (error.status === 404 || error.codes.includes(SCRIPT_NOT_FOUND_CODE)) + ) { + return null; + } + throw error; + } + }, + async patchScriptBindings(scriptName, bindings) { + const form = new FormData(); + form.append( + 'settings', + new Blob([JSON.stringify({ bindings })], { type: 'application/json' }), + ); + const result = await request<{ bindings?: ReadonlyArray } | null>( + 'PATCH', + `/${encodeURIComponent(scriptName)}/settings`, + form, + ); + return Array.isArray(result?.bindings) ? result.bindings : null; + }, + }; +} diff --git a/platform/packages/api/src/proxy-token-rotation.ts b/platform/packages/api/src/proxy-token-rotation.ts index 166302722..3e288705a 100644 --- a/platform/packages/api/src/proxy-token-rotation.ts +++ b/platform/packages/api/src/proxy-token-rotation.ts @@ -17,6 +17,9 @@ export const PROXY_TOKEN_ROTATION_JOB_KIND = 'proxy-token-rotation'; /** How long a rotation waits for KV copies and old Worker versions to drain. */ export const PROXY_TOKEN_ROTATION_WAIT_MS = 120_000; +/** Cloudflare API calls and wall-clock time one rotation run may still spend. */ +export type RotationBudget = { cfCallsRemaining: number; readonly deadline: number }; + /** Primary key of a project's rotation row in `project_cleanup_jobs`. */ export function proxyTokenRotationTarget(projectId: string): string { return `${PROXY_TOKEN_ROTATION_JOB_KIND}:${projectId}`; diff --git a/platform/packages/api/test/unit/proxy-token-rotation-cloudflare.test.ts b/platform/packages/api/test/unit/proxy-token-rotation-cloudflare.test.ts new file mode 100644 index 000000000..d748cb5ce --- /dev/null +++ b/platform/packages/api/test/unit/proxy-token-rotation-cloudflare.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vite-plus/test'; +import { CloudflareApiError } from '../../src/provisioning/cloudflare-api.ts'; +import { + buildProxyTokenBindingsPatch, + CloudflareRateLimitError, + createRotationCloudflareClient, + RotationBudgetExhaustedError, + type ScriptBinding, +} from '../../src/proxy-token-rotation-cloudflare.ts'; +import type { RotationBudget } from '../../src/proxy-token-rotation.ts'; + +const ACCOUNT = 'acc_test'; +const API_TOKEN = 'cf-token'; +const NAMESPACE = 'void-tenants'; +const SCRIPT = 'user-demo-dep_000000000001'; +const SCRIPT_URL = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT}/workers/dispatch/namespaces/${NAMESPACE}/scripts/${SCRIPT}`; + +const BINDINGS: ReadonlyArray = [ + { name: 'DB', type: 'd1' }, + { name: '__VOID_PROXY', type: 'service' }, + { name: '__VOID_PROXY_TOKEN', type: 'plain_text', text: 'old' }, + { name: 'S', type: 'secret_text' }, +]; + +function budget(cfCallsRemaining = 10): RotationBudget { + return { cfCallsRemaining, deadline: Date.now() + 60_000 }; +} + +function client(fetchMock: typeof fetch, runBudget: RotationBudget = budget()) { + return createRotationCloudflareClient({ + accountId: ACCOUNT, + apiToken: API_TOKEN, + dispatchNamespace: NAMESPACE, + budget: runBudget, + fetch: fetchMock, + }); +} + +function cfSuccess(result: unknown): Response { + return Response.json({ success: true, errors: [], result }); +} + +describe('buildProxyTokenBindingsPatch', () => { + it('inherits every other binding in order and sets the token', () => { + expect(buildProxyTokenBindingsPatch(BINDINGS, 'new')).toStrictEqual([ + { type: 'inherit', name: 'DB' }, + { type: 'inherit', name: '__VOID_PROXY' }, + { type: 'plain_text', name: '__VOID_PROXY_TOKEN', text: 'new' }, + { type: 'inherit', name: 'S' }, + ]); + }); + + it('throws when the script has no token binding', () => { + expect(() => + buildProxyTokenBindingsPatch( + BINDINGS.filter((binding) => binding.name !== '__VOID_PROXY_TOKEN'), + 'new', + ), + ).toThrow(/__VOID_PROXY_TOKEN/); + }); +}); + +describe('createRotationCloudflareClient', () => { + it('reads script bindings', async () => { + const fetchMock = vi.fn(async () => cfSuccess(BINDINGS)); + await expect(client(fetchMock).getScriptBindings(SCRIPT)).resolves.toStrictEqual(BINDINGS); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe(`${SCRIPT_URL}/bindings`); + expect(init?.method).toBe('GET'); + expect(init?.headers).toStrictEqual({ Authorization: `Bearer ${API_TOKEN}` }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + it('sends the settings PATCH as one multipart settings part', async () => { + const patched: ReadonlyArray = [ + { name: '__VOID_PROXY_TOKEN', type: 'plain_text', text: 'new' }, + ]; + const fetchMock = vi.fn(async () => cfSuccess({ bindings: patched })); + const bindings = buildProxyTokenBindingsPatch(BINDINGS, 'new'); + + await expect(client(fetchMock).patchScriptBindings(SCRIPT, bindings)).resolves.toStrictEqual( + patched, + ); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe(`${SCRIPT_URL}/settings`); + expect(init?.method).toBe('PATCH'); + expect(init?.headers).toStrictEqual({ Authorization: `Bearer ${API_TOKEN}` }); + const form = init?.body; + expect(form).toBeInstanceOf(FormData); + expect([...(form as FormData).keys()]).toStrictEqual(['settings']); + const part = (form as FormData).get('settings') as Blob; + expect(part.type).toBe('application/json'); + expect(JSON.parse(await part.text())).toStrictEqual({ bindings }); + }); + + it('returns null when the PATCH result has no bindings', async () => { + const fetchMock = vi.fn(async () => cfSuccess({ compatibility_date: 'x' })); + await expect( + client(fetchMock).patchScriptBindings(SCRIPT, [{ type: 'inherit', name: 'DB' }]), + ).resolves.toBeNull(); + }); + + it.each([ + ['HTTP 404', () => Response.json({ success: false, errors: [] }, { status: 404 })], + [ + 'code 10007', + () => + Response.json( + { + success: false, + errors: [{ code: 10007, message: 'workers.api.error.script_not_found' }], + }, + { status: 400 }, + ), + ], + ])('returns null for a missing script (%s)', async (_label, response) => { + const fetchMock = vi.fn(async () => response()); + await expect(client(fetchMock).getScriptBindings(SCRIPT)).resolves.toBeNull(); + }); + + it('throws other Cloudflare errors with their status and codes', async () => { + const fetchMock = vi.fn(async () => + Response.json( + { success: false, errors: [{ code: 10000, message: 'boom' }] }, + { status: 500 }, + ), + ); + const error = await client(fetchMock) + .getScriptBindings(SCRIPT) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(CloudflareApiError); + expect(error).toMatchObject({ status: 500, codes: [10000], message: /boom/ }); + }); + + it('throws a rate limit with the Retry-After seconds and makes one call', async () => { + const fetchMock = vi.fn( + async () => new Response('slow down', { status: 429, headers: { 'Retry-After': '3' } }), + ); + const error = await client(fetchMock) + .getScriptBindings(SCRIPT) + .catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(CloudflareRateLimitError); + expect((error as CloudflareRateLimitError).retryAfterSeconds).toBe(3); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('defaults the rate limit wait to 60 seconds', async () => { + const fetchMock = vi.fn(async () => new Response('', { status: 429 })); + await expect(client(fetchMock).getScriptBindings(SCRIPT)).rejects.toMatchObject({ + retryAfterSeconds: 60, + }); + }); + + it('counts each call against the budget and stops before fetch when it is spent', async () => { + const fetchMock = vi.fn(async () => cfSuccess(BINDINGS)); + const runBudget = budget(1); + const rotation = client(fetchMock, runBudget); + + await rotation.getScriptBindings(SCRIPT); + expect(runBudget.cfCallsRemaining).toBe(0); + await expect(rotation.getScriptBindings(SCRIPT)).rejects.toBeInstanceOf( + RotationBudgetExhaustedError, + ); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('stops before fetch when the deadline has passed', async () => { + const fetchMock = vi.fn(async () => cfSuccess(BINDINGS)); + const runBudget = { cfCallsRemaining: 5, deadline: Date.now() - 1 }; + await expect(client(fetchMock, runBudget).getScriptBindings(SCRIPT)).rejects.toBeInstanceOf( + RotationBudgetExhaustedError, + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(runBudget.cfCallsRemaining).toBe(5); + }); +}); From d9a329acc2ac61b7aeaab2d8f58e677319abc6ed Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 02:55:42 +0800 Subject: [PATCH 03/29] Rotate a project's proxy token across its Workers A rotation is one project_cleanup_jobs row that moves a project to the next proxy token generation without a redeploy: - START raises projects.proxy_token_gen in Postgres alone, so the new token is committed before anything outside sees it. - COPIES gives the queue routing entries and the cron scheduler the new token as the fallback. - PATCH walks the active and retained Workers one per transaction: it reads each script's bindings and replaces __VOID_PROXY_TOKEN through a settings PATCH that inherits every other binding. - REVOKE checks the active Worker again, then writes the accept floor. - SETTLE waits until every floor reader sees it and ends the job, or starts one more generation when a request arrived after START. Each step locks the project row, then the job row, and waits while a deployment is in flight. Waits live in the payload and start at clock_timestamp() after the step's last outside write, so kicking the job never cuts one short; only a real PATCH restarts the wait before REVOKE. A failure leaves both tokens valid and retries on the 15 minute lease. A rate limit or a spent call budget defers the job without counting a failure. The scheduled handler advances due rotations with at most 100 Cloudflare calls and 25 seconds per tick, skipped under the development auth bypass or without an API token. Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/src/index.ts | 11 +- .../packages/api/src/proxy-token-rotation.ts | 810 +++++++++++- .../packages/api/test/integration/helpers.ts | 83 ++ .../integration/proxy-token-rotation.test.ts | 1127 +++++++++++++++++ .../api/test/integration/scheduled.test.ts | 72 +- 5 files changed, 2095 insertions(+), 8 deletions(-) create mode 100644 platform/packages/api/test/integration/proxy-token-rotation.test.ts diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index 4c07f3dc5..cbc5f54c3 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -63,7 +63,7 @@ import { isValidEmailDomain, MAX_DOMAIN_LENGTH } from './email/domain-name.ts'; import emailIngressResolution from './email/ingress-resolution.ts'; import { repairEmailRecipientPolicyMirror } from './email/settings.ts'; import { reconcileSharedEmailRecipients } from './email/shared-recipient-service.ts'; -import type { ApiEnv } from './env.ts'; +import { isDevBypass, type ApiEnv } from './env.ts'; import { ErrorAlertMonitor } from './error-alert-monitor.ts'; import { cancelContainerBuildDos } from './github/cancel-builds.ts'; import { authMiddleware } from './middleware/auth.ts'; @@ -94,6 +94,7 @@ import { } from './provisioning/cloudflare-api.ts'; import { reconcileEmailDestinationCleanups } from './provisioning/email-destination-cleanup.ts'; import { retrySandboxControllerCleanup } from './provisioning/sandbox-controller.ts'; +import { reconcileProxyTokenRotations } from './proxy-token-rotation.ts'; import { oidcRoutes } from './routes/auth-oidc.ts'; import auth from './routes/auth.ts'; import deleteRoute from './routes/delete.ts'; @@ -668,6 +669,14 @@ async function scheduled( } } + // Proxy token rotations, bounded per tick so deploys keep their share of the + // Cloudflare API rate limit. + if (!isDevBypass(env) && env.CF_API_TOKEN) { + await reconcileProxyTokenRotations(db, env, { maxCfCalls: 100, maxMs: 25_000 }).catch((error) => + console.error('[scheduled] proxy token rotation failed:', error), + ); + } + if (capabilities.email) { await reconcileEmailDestinationCleanups(db, { accountId: env.CF_ACCOUNT_ID, diff --git a/platform/packages/api/src/proxy-token-rotation.ts b/platform/packages/api/src/proxy-token-rotation.ts index 3e288705a..52f961b38 100644 --- a/platform/packages/api/src/proxy-token-rotation.ts +++ b/platform/packages/api/src/proxy-token-rotation.ts @@ -1,8 +1,21 @@ -import { eq } from 'drizzle-orm'; -import type { Db } from './db.ts'; -import { deriveProxyToken } from './proxy-token.ts'; -import { projects } from './schema.ts'; -import type { Project } from './types.ts'; +import { and, asc, eq, or, sql, type SQL } from 'drizzle-orm'; +import { DEFAULT_DISPATCH_NAMESPACE } from './constants.ts'; +import { snapshotCronScheduler, syncCronScheduler } from './cron-scheduler-client.ts'; +import { getActiveDeployment, type Db, type DbOrTx } from './db.ts'; +import type { ApiEnv } from './env.ts'; +import { getProjectDeploymentWorkerName, getProjectQueueName } from './naming.ts'; +import { requireNormalizedProject } from './project-state.ts'; +import { + buildProxyTokenBindingsPatch, + CloudflareRateLimitError, + createRotationCloudflareClient, + RotationBudgetExhaustedError, + type RotationCloudflareClient, + type ScriptBinding, +} from './proxy-token-rotation-cloudflare.ts'; +import { deriveProxyToken, proxyTokenStateKey, serializeProxyTokenState } from './proxy-token.ts'; +import { deployments, projectCleanupJobs, projects } from './schema.ts'; +import type { DeployManifest, Project } from './types.ts'; /** * Proxy token generations per project. @@ -64,3 +77,790 @@ export async function proxyTokenCopies( ? { proxyToken: workerToken } : { proxyToken: workerToken, proxyTokenFallback: issued }; } + +// --- Rotation --- +// +// A rotation is one `project_cleanup_jobs` row (kind `proxy-token-rotation`) +// that moves through these phases, one short transaction per step: +// +// requested START: raise projects.proxy_token_gen to T. Postgres only, so +// T is committed before anything outside sees tok(T). +// copies queue entries and the cron scheduler get tok(T) as fallback. +// patch one Worker per step: GET its bindings, PATCH tok(T) in. +// revoke every Worker holds tok(T): write the accept floor T. +// settle wait until every floor reader sees T, then finish. +// +// Every step locks the project row, then the job row, and refuses while a +// deployment is in flight (the same fence as deploys, rollback and secret +// writes). A wait is stored in the payload as `waitUntil`, taken from +// `clock_timestamp()` after the step's last outside write, so a kick of +// `next_attempt_at` never cuts it short. Until the floor rises both tokens are +// accepted, so a failed or unfinished rotation never breaks a tenant. + +export type ProxyTokenRotationReason = + | 'member-removed' + | 'member-role-changed' + | 'user-deleted' + | 'untrusted-deploy-replaced' + | 'operator' + | 'operator-bulk' + | 'legacy-init'; + +export type ProxyTokenRotationPhase = 'requested' | 'copies' | 'patch' | 'revoke' | 'settle'; + +export type ProxyTokenRotationEnv = Pick< + ApiEnv['Bindings'], + | 'ROUTING' + | 'CRON_SCHEDULER' + | 'JWT_SECRET' + | 'CF_ACCOUNT_ID' + | 'CF_API_TOKEN' + | 'DISPATCH_NAMESPACE' +>; + +export type ProjectRotationOutcome = + | { status: 'done'; gen: number } + | { + status: 'pending'; + phase: ProxyTokenRotationPhase; + reason: 'waiting' | 'busy' | 'budget' | 'rate-limited'; + retryAfterSeconds: number; + } + | { status: 'failed'; phase: ProxyTokenRotationPhase; error: string; workerName?: string }; + +export type RotationRunSummary = { + done: number; + pending: number; + failed: number; + retryAfterSeconds: number | null; +}; + +const PROXY_TOKEN_BINDING = '__VOID_PROXY_TOKEN'; +const CLAIM_LEASE = sql`interval '15 minutes'`; +const BUSY_RETRY_SECONDS = 120; + +function isStaticProjectType(type: Project['type']): boolean { + return type === 'static' || type === 'spa'; +} + +/** `expr` (a timestamptz) as an ISO-8601 UTC string with microseconds. */ +function isoUtc(expr: SQL): SQL { + return sql`to_char((${expr}) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`; +} + +function waitInterval(waitMs: number): SQL { + return sql`(${Math.max(0, Math.trunc(waitMs))}::integer * interval '1 millisecond')`; +} + +const jobTarget = (projectId: string) => + eq(projectCleanupJobs.target, proxyTokenRotationTarget(projectId)); + +function rotationLog(event: string, projectId: string, detail = ''): void { + console.log(`[proxy-token-rotation] ${event} ${projectId}${detail ? ` ${detail}` : ''}`); +} + +// --- Requests --- + +/** + * Asks for a new token generation. A job that has not started yet already + * covers the request; one past START may have handed out tok(T), so it + * finishes and then runs once more (`again`). Operator requests also make the + * job due now. Call inside the caller's transaction. + */ +export async function requestProxyTokenRotation( + tx: DbOrTx, + projectId: string, + reason: ProxyTokenRotationReason, + mode: 'member' | 'operator', +): Promise { + const flagAgain = sql`CASE WHEN ${projectCleanupJobs.payload}->>'phase' <> 'requested' + THEN ${projectCleanupJobs.payload} || '{"again":"1"}'::jsonb + ELSE ${projectCleanupJobs.payload} END`; + const rows = await tx + .insert(projectCleanupJobs) + .values({ + target: proxyTokenRotationTarget(projectId), + project_id: projectId, + kind: PROXY_TOKEN_ROTATION_JOB_KIND, + payload: { phase: 'requested', reason }, + next_attempt_at: sql`now()`, + }) + .onConflictDoUpdate({ + target: projectCleanupJobs.target, + set: + mode === 'operator' + ? { payload: flagAgain, next_attempt_at: sql`now()` } + : { payload: flagAgain }, + ...(mode === 'member' + ? { setWhere: sql`${projectCleanupJobs.payload}->>'phase' <> 'requested'` } + : {}), + }) + .returning({ phase: sql`${projectCleanupJobs.payload}->>'phase'` }); + if (rows.length > 0) { + rotationLog('requested', projectId, `reason=${reason}`); + } +} + +/** + * Bulk requests. `legacy` queues every project still on generation 0 that has + * no job; `all` queues every project, with the member rule on an existing job. + * Returns the number of jobs inserted or flagged. + */ +export async function requestBulkProxyTokenRotations( + db: DbOrTx, + scope: 'legacy' | 'all', +): Promise { + const reason: ProxyTokenRotationReason = 'operator-bulk'; + const result = await db.execute(sql` + INSERT INTO ${projectCleanupJobs} (target, project_id, kind, payload, next_attempt_at) + SELECT ${PROXY_TOKEN_ROTATION_JOB_KIND}::text || ':' || ${projects.id}, ${projects.id}, + ${PROXY_TOKEN_ROTATION_JOB_KIND}::text, jsonb_build_object('phase', 'requested', 'reason', ${reason}::text), now() + FROM ${projects} + WHERE ${projects.status} <> 'deleting' + ${scope === 'legacy' ? sql`AND ${projects.proxy_token_gen} = 0` : sql``} + ${ + scope === 'legacy' + ? sql`ON CONFLICT (target) DO NOTHING` + : sql`ON CONFLICT (target) DO UPDATE + SET payload = project_cleanup_jobs.payload || '{"again":"1"}'::jsonb + WHERE project_cleanup_jobs.payload->>'phase' <> 'requested'` + } + RETURNING target`); + const count = result.rows.length; + if (count > 0) { + console.log(`[proxy-token-rotation] requested ${count} project(s) scope=${scope}`); + } + return count; +} + +// --- Inventory --- + +type InventoryRow = Readonly<{ + deploymentId: string; + workerName: string; + createdAt: string; + active: boolean; +}>; + +function inventoryWhere( + project: Pick, + after?: Readonly<{ createdAt: string; id: string }>, +): SQL | undefined { + return and( + eq(deployments.project_id, project.id), + project.active_deployment_id + ? or(eq(deployments.retained, true), eq(deployments.id, project.active_deployment_id)) + : eq(deployments.retained, true), + sql`${deployments.source} IS DISTINCT FROM 'rollback-lease'`, + after + ? sql`(${deployments.created_at}, ${deployments.id}) > (${after.createdAt}::timestamp, ${after.id})` + : undefined, + ); +} + +async function selectInventory( + db: DbOrTx, + project: Pick, + options: Readonly<{ after?: { createdAt: string; id: string }; limit?: number }> = {}, +): Promise> { + const query = db + .select({ id: deployments.id, createdAt: deployments.created_at }) + .from(deployments) + .where(inventoryWhere(project, options.after)) + .orderBy(asc(deployments.created_at), asc(deployments.id)); + const rows = options.limit === undefined ? await query : await query.limit(options.limit); + return rows.map((row) => ({ + deploymentId: row.id, + workerName: getProjectDeploymentWorkerName(project.slug, row.id), + createdAt: row.createdAt, + active: row.id === project.active_deployment_id, + })); +} + +/** + * Every Worker that may hold the project's token: the active deployment and + * every retained one (rollback leases excluded), oldest first. Rows of a + * static deployment stay in; their GET finds no script. + */ +export function listProxyTokenInventory( + db: DbOrTx, + project: Pick, +): Promise> { + return selectInventory(db, project); +} + +function parseCursor(cursor: string | undefined): { createdAt: string; id: string } | undefined { + const split = cursor ? cursor.indexOf('|') : -1; + return split > 0 + ? { createdAt: cursor!.slice(0, split), id: cursor!.slice(split + 1) } + : undefined; +} + +// --- Steps --- + +type JobPayload = Readonly>; + +type StepContext = { + readonly projectId: string; + readonly env: ProxyTokenRotationEnv; + readonly cf: RotationCloudflareClient; + readonly wait: SQL; + phase: ProxyTokenRotationPhase; + workerName?: string; +}; + +type StepResult = + | { kind: 'continue' } + | { kind: 'done'; gen: number } + | { kind: 'waiting'; retryAfterSeconds: number } + | { kind: 'busy' }; + +type LockedStep = { + project: Project; + payload: JobPayload; +}; + +async function issuedGeneration(db: DbOrTx, projectId: string): Promise { + const [row] = await db + .select({ gen: projects.proxy_token_gen }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + return row?.gen ?? 0; +} + +/** Locks the job row and checks its wait. Null when the job is gone. */ +async function lockJob( + tx: DbOrTx, + projectId: string, +): Promise<{ payload: JobPayload; waitSeconds: number | null } | null> { + const [job] = await tx + .select({ + payload: projectCleanupJobs.payload, + waitSeconds: sql`CASE + WHEN (${projectCleanupJobs.payload}->>'waitUntil')::timestamptz > now() + THEN ceil(extract(epoch FROM (${projectCleanupJobs.payload}->>'waitUntil')::timestamptz - now()))::integer + END`, + }) + .from(projectCleanupJobs) + .where(and(jobTarget(projectId), eq(projectCleanupJobs.kind, PROXY_TOKEN_ROTATION_JOB_KIND))) + .for('update'); + return job ? { payload: job.payload, waitSeconds: job.waitSeconds } : null; +} + +/** Project lock, then job lock, then the wait, then no deployment in flight. */ +async function lockStep(tx: DbOrTx, ctx: StepContext): Promise { + const [project] = await tx + .select() + .from(projects) + .where(eq(projects.id, ctx.projectId)) + .for('update'); + if (!project || project.status === 'deleting') { + await tx.delete(projectCleanupJobs).where(jobTarget(ctx.projectId)); + rotationLog('dropped', ctx.projectId, 'project deleted'); + return { kind: 'done', gen: project?.proxy_token_gen ?? 0 }; + } + const job = await lockJob(tx, ctx.projectId); + if (!job) { + return { kind: 'done', gen: project.proxy_token_gen }; + } + ctx.phase = job.payload.phase as ProxyTokenRotationPhase; + if (job.waitSeconds !== null) { + return { kind: 'waiting', retryAfterSeconds: job.waitSeconds }; + } + if (await getActiveDeployment(tx, ctx.projectId)) { + return { kind: 'busy' }; + } + return { project, payload: job.payload }; +} + +/** Final statement of a successful step: merge `fields` into the payload. */ +async function finishStep(tx: DbOrTx, projectId: string, fields: SQL): Promise { + await tx + .update(projectCleanupJobs) + .set({ + payload: sql`${projectCleanupJobs.payload} || ${fields}`, + attempts: 0, + last_error: null, + }) + .where(jobTarget(projectId)); +} + +type ActiveWorker = Readonly<{ workerName: string; manifest: DeployManifest | null }>; + +async function activeWorker(tx: DbOrTx, project: Project): Promise { + if (!project.active_deployment_id) { + return null; + } + const [row] = await tx + .select({ manifest: deployments.manifest }) + .from(deployments) + .where(eq(deployments.id, project.active_deployment_id)) + .limit(1); + let manifest: DeployManifest | null = null; + try { + manifest = row?.manifest ? (JSON.parse(row.manifest) as DeployManifest) : null; + } catch { + manifest = null; + } + return { + workerName: getProjectDeploymentWorkerName(project.slug, project.active_deployment_id), + manifest, + }; +} + +/** Whether the active Worker receives queue or cron traffic through a copy of its token. */ +function hasTokenCopies(project: Project, active: ActiveWorker | null): active is ActiveWorker { + return ( + !!active && + !isStaticProjectType(project.type) && + (!!active.manifest?.schedules?.length || !!active.manifest?.queues?.length) + ); +} + +/** + * Rewrites the queue routing entries and the cron scheduler of the active + * Worker. Built from the active manifest, never from what KV holds. A field + * left out removes it. + */ +async function writeCopies( + env: ProxyTokenRotationEnv, + projectId: string, + active: ActiveWorker, + tokens: Readonly<{ proxyToken?: string; proxyTokenFallback?: string }>, +): Promise { + if (!tokens.proxyToken) { + return; + } + const fields = { + proxyToken: tokens.proxyToken, + ...(tokens.proxyTokenFallback ? { proxyTokenFallback: tokens.proxyTokenFallback } : {}), + }; + if (active.manifest?.schedules?.length) { + const state = await snapshotCronScheduler(env, projectId); + if (state && state.workerName === active.workerName) { + const { proxyToken: _token, proxyTokenFallback: _fallback, ...rest } = state; + await syncCronScheduler(env, projectId, { ...rest, ...fields }); + } + } + for (const queue of active.manifest?.queues ?? []) { + await env.ROUTING.put( + `queue:${getProjectQueueName(projectId, queue.name)}`, + JSON.stringify({ + workerName: active.workerName, + projectId, + queueName: queue.name, + ...fields, + }), + ); + } +} + +/** The proxy token binding, `undefined` when the Worker has none. */ +function tokenBinding(bindings: ReadonlyArray): ScriptBinding | undefined { + return bindings.find((binding) => binding.name === PROXY_TOKEN_BINDING); +} + +async function start(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Promise { + await requireNormalizedProject(tx, locked.project); + const gen = locked.project.proxy_token_gen + 1; + await tx.update(projects).set({ proxy_token_gen: gen }).where(eq(projects.id, ctx.projectId)); + await tx + .update(projectCleanupJobs) + .set({ + payload: sql`jsonb_build_object('phase', 'copies', 'reason', ${projectCleanupJobs.payload}->>'reason', 'gen', ${String(gen)}::text, 'cursor', '')`, + attempts: 0, + last_error: null, + }) + .where(jobTarget(ctx.projectId)); + rotationLog('started', ctx.projectId, `gen=${gen}`); + return { kind: 'continue' }; +} + +async function copies(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Promise { + const active = await activeWorker(tx, locked.project); + if (hasTokenCopies(locked.project, active)) { + ctx.workerName = active.workerName; + const bindings = await ctx.cf.getScriptBindings(active.workerName); + const current = bindings ? tokenBinding(bindings) : undefined; + if (current?.type === 'plain_text' && current.text) { + await writeCopies( + ctx.env, + ctx.projectId, + active, + await proxyTokenCopies(ctx.env.JWT_SECRET, locked.project, current.text), + ); + } + } + await finishStep( + tx, + ctx.projectId, + sql`jsonb_build_object('phase', 'patch', 'waitUntil', ${isoUtc(sql`clock_timestamp() + ${ctx.wait}`)})`, + ); + return { kind: 'continue' }; +} + +async function patchNext(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Promise { + const { project, payload } = locked; + const [row] = await selectInventory(tx, project, { + after: parseCursor(payload.cursor), + limit: 1, + }); + if (!row) { + // Old versions of a patched Worker drain for one wait after the last PATCH. + await finishStep( + tx, + ctx.projectId, + sql`jsonb_build_object('phase', 'revoke', 'waitUntil', CASE + WHEN ${projectCleanupJobs.payload}->>'lastPatchAt' IS NOT NULL + THEN ${isoUtc(sql`(${projectCleanupJobs.payload}->>'lastPatchAt')::timestamptz + ${ctx.wait}`)} + ELSE ${isoUtc(sql`clock_timestamp()`)} END)`, + ); + return { kind: 'continue' }; + } + ctx.workerName = row.workerName; + const token = await deriveProxyToken(ctx.env.JWT_SECRET, ctx.projectId, project.proxy_token_gen); + const bindings = await ctx.cf.getScriptBindings(row.workerName); + let patched = false; + if (bindings === null) { + if (row.active && !isStaticProjectType(project.type)) { + throw new Error(`active Worker ${row.workerName} not found`); + } + } else { + const current = tokenBinding(bindings); + if (current && current.type !== 'plain_text') { + throw new Error(`Worker ${row.workerName} has a ${current.type} proxy token binding`); + } + if (current && current.text !== token) { + const result = + (await ctx.cf.patchScriptBindings( + row.workerName, + buildProxyTokenBindingsPatch(bindings, token), + )) ?? (await ctx.cf.getScriptBindings(row.workerName)); + if (tokenBinding(result ?? [])?.text !== token) { + throw new Error(`Worker ${row.workerName} did not take the new proxy token`); + } + patched = true; + rotationLog( + 'patched', + ctx.projectId, + `worker=${row.workerName} gen=${project.proxy_token_gen}`, + ); + if (row.active) { + const active = await activeWorker(tx, project); + if (hasTokenCopies(project, active)) { + await writeCopies(ctx.env, ctx.projectId, active, { + proxyToken: token, + proxyTokenFallback: current.text, + }); + } + } + } + } + const cursor = `${row.createdAt}|${row.deploymentId}`; + await finishStep( + tx, + ctx.projectId, + patched + ? sql`jsonb_build_object('cursor', ${cursor}::text, 'lastPatchAt', ${isoUtc(sql`clock_timestamp()`)})` + : sql`jsonb_build_object('cursor', ${cursor}::text)`, + ); + return { kind: 'continue' }; +} + +async function revoke(tx: DbOrTx, ctx: StepContext, locked: LockedStep): Promise { + const { project, payload } = locked; + const [later] = await selectInventory(tx, project, { + after: parseCursor(payload.cursor), + limit: 1, + }); + if (later) { + // A deploy finished after the walk passed; patch (or skip) it first. + await finishStep(tx, ctx.projectId, sql`'{"phase":"patch"}'::jsonb`); + return { kind: 'continue' }; + } + const gen = project.proxy_token_gen; + const token = await deriveProxyToken(ctx.env.JWT_SECRET, ctx.projectId, gen); + const active = await activeWorker(tx, project); + if (active) { + ctx.workerName = active.workerName; + const bindings = await ctx.cf.getScriptBindings(active.workerName); + if (bindings === null && !isStaticProjectType(project.type)) { + throw new Error(`active Worker ${active.workerName} not found`); + } + const current = bindings ? tokenBinding(bindings) : undefined; + if (current && current.text !== token) { + await finishStep(tx, ctx.projectId, sql`'{"phase":"patch","cursor":""}'::jsonb`); + return { kind: 'continue' }; + } + if (hasTokenCopies(project, active)) { + await writeCopies(ctx.env, ctx.projectId, active, { proxyToken: token }); + } + } + // Written before COMMIT: KV may only run ahead, and every Worker holds T. + await ctx.env.ROUTING.put(proxyTokenStateKey(ctx.projectId), serializeProxyTokenState({ gen })); + await finishStep( + tx, + ctx.projectId, + sql`jsonb_build_object('phase', 'settle', 'waitUntil', ${isoUtc(sql`clock_timestamp() + ${ctx.wait}`)})`, + ); + rotationLog('revoked', ctx.projectId, `gen=${gen}`); + return { kind: 'continue' }; +} + +/** Holds only the job row lock: it touches nothing outside Postgres. */ +async function settle(tx: DbOrTx, ctx: StepContext): Promise { + const job = await lockJob(tx, ctx.projectId); + if (!job) { + return { kind: 'done', gen: await issuedGeneration(tx, ctx.projectId) }; + } + ctx.phase = job.payload.phase as ProxyTokenRotationPhase; + if (ctx.phase !== 'settle') { + return { kind: 'continue' }; + } + if (job.waitSeconds !== null) { + return { kind: 'waiting', retryAfterSeconds: job.waitSeconds }; + } + if (job.payload.again === '1') { + await tx + .update(projectCleanupJobs) + .set({ + payload: sql`jsonb_build_object('phase', 'requested', 'reason', ${projectCleanupJobs.payload}->>'reason')`, + attempts: 0, + last_error: null, + }) + .where(jobTarget(ctx.projectId)); + rotationLog('rotated', ctx.projectId, `gen=${job.payload.gen} again`); + return { kind: 'continue' }; + } + await tx.delete(projectCleanupJobs).where(jobTarget(ctx.projectId)); + rotationLog('rotated', ctx.projectId, `gen=${job.payload.gen}`); + return { kind: 'done', gen: Number(job.payload.gen) }; +} + +function runStep(db: Db, ctx: StepContext): Promise { + return db.transaction(async (tx) => { + const [peek] = await tx + .select({ phase: sql`${projectCleanupJobs.payload}->>'phase'` }) + .from(projectCleanupJobs) + .where(jobTarget(ctx.projectId)); + if (!peek) { + return { kind: 'done', gen: await issuedGeneration(tx, ctx.projectId) }; + } + ctx.phase = peek.phase as ProxyTokenRotationPhase; + ctx.workerName = undefined; + if (peek.phase === 'settle') { + return settle(tx, ctx); + } + const locked = await lockStep(tx, ctx); + if (!('project' in locked)) { + return locked; + } + switch (locked.payload.phase as ProxyTokenRotationPhase) { + case 'requested': + return start(tx, ctx, locked); + case 'copies': + return copies(tx, ctx, locked); + case 'patch': + return patchNext(tx, ctx, locked); + case 'revoke': + return revoke(tx, ctx, locked); + default: + throw new Error(`unknown rotation phase ${String(locked.payload.phase)}`); + } + }); +} + +// --- Runs --- + +async function setNextAttempt(db: Db, projectId: string, at: SQL): Promise { + await db.update(projectCleanupJobs).set({ next_attempt_at: at }).where(jobTarget(projectId)); +} + +/** Runs the due steps of a job this run has claimed. */ +async function runClaimed( + db: Db, + env: ProxyTokenRotationEnv, + projectId: string, + budget: RotationBudget, + options: Readonly<{ waitMs?: number; fetch?: typeof fetch }>, +): Promise { + const ctx: StepContext = { + projectId, + env, + cf: createRotationCloudflareClient({ + accountId: env.CF_ACCOUNT_ID, + apiToken: env.CF_API_TOKEN, + dispatchNamespace: env.DISPATCH_NAMESPACE ?? DEFAULT_DISPATCH_NAMESPACE, + budget, + fetch: options.fetch, + }), + wait: waitInterval(options.waitMs ?? PROXY_TOKEN_ROTATION_WAIT_MS), + phase: 'requested', + }; + for (;;) { + let result: StepResult; + try { + if (Date.now() >= budget.deadline) { + throw new RotationBudgetExhaustedError(); + } + result = await runStep(db, ctx); + } catch (error) { + if (error instanceof CloudflareRateLimitError) { + await setNextAttempt( + db, + projectId, + sql`now() + ${error.retryAfterSeconds}::integer * interval '1 second'`, + ); + return { + status: 'pending', + phase: ctx.phase, + reason: 'rate-limited', + retryAfterSeconds: error.retryAfterSeconds, + }; + } + if (error instanceof RotationBudgetExhaustedError) { + await setNextAttempt(db, projectId, sql`now()`); + return { status: 'pending', phase: ctx.phase, reason: 'budget', retryAfterSeconds: 0 }; + } + const message = (error instanceof Error ? error.message : String(error)) + .replaceAll(env.CF_API_TOKEN || '\u0000', '[redacted]') + .slice(0, 500); + const [row] = await db + .update(projectCleanupJobs) + .set({ + attempts: sql`${projectCleanupJobs.attempts} + 1`, + last_error: message, + next_attempt_at: sql`now() + ${CLAIM_LEASE}`, + }) + .where(jobTarget(projectId)) + .returning({ attempts: projectCleanupJobs.attempts }); + console.error( + `[proxy-token-rotation] FAILED ${projectId} phase=${ctx.phase} attempts=${row?.attempts ?? 0} worker=${ctx.workerName ?? '-'}: ${message}`, + ); + return { + status: 'failed', + phase: ctx.phase, + error: message, + ...(ctx.workerName ? { workerName: ctx.workerName } : {}), + }; + } + switch (result.kind) { + case 'continue': + continue; + case 'done': + return { status: 'done', gen: result.gen }; + case 'waiting': + await setNextAttempt( + db, + projectId, + sql`(${projectCleanupJobs.payload}->>'waitUntil')::timestamptz`, + ); + return { + status: 'pending', + phase: ctx.phase, + reason: 'waiting', + retryAfterSeconds: result.retryAfterSeconds, + }; + case 'busy': + await setNextAttempt( + db, + projectId, + sql`now() + ${BUSY_RETRY_SECONDS}::integer * interval '1 second'`, + ); + return { + status: 'pending', + phase: ctx.phase, + reason: 'busy', + retryAfterSeconds: BUSY_RETRY_SECONDS, + }; + } + } +} + +/** Takes the lease on one due job. A busy or waiting job is not a failure, so `attempts` stays. */ +async function claimDueJob(db: Db, projectId?: string): Promise { + const result = await db.execute<{ project_id: string }>(sql` + UPDATE ${projectCleanupJobs} SET next_attempt_at = now() + ${CLAIM_LEASE} + WHERE target = ( + SELECT target FROM ${projectCleanupJobs} + WHERE kind = ${PROXY_TOKEN_ROTATION_JOB_KIND} AND next_attempt_at <= now() + ${projectId ? sql`AND target = ${proxyTokenRotationTarget(projectId)}` : sql``} + ORDER BY next_attempt_at, created_at + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING project_id`); + return result.rows[0]?.project_id ?? null; +} + +/** Runs the due steps of one project's job. Claims it first. */ +export async function advanceProxyTokenRotation( + db: Db, + env: ProxyTokenRotationEnv, + projectId: string, + budget: RotationBudget, + options: Readonly<{ waitMs?: number; fetch?: typeof fetch }> = {}, +): Promise { + if (await claimDueJob(db, projectId)) { + return runClaimed(db, env, projectId, budget, options); + } + const [job] = await db + .select({ + phase: sql`${projectCleanupJobs.payload}->>'phase'`, + retryAfterSeconds: sql`greatest(0, ceil(extract(epoch FROM ${projectCleanupJobs.next_attempt_at}::timestamptz - now())))::integer`, + }) + .from(projectCleanupJobs) + .where(jobTarget(projectId)); + if (!job) { + return { status: 'done', gen: await issuedGeneration(db, projectId) }; + } + return { + status: 'pending', + phase: job.phase, + reason: 'waiting', + retryAfterSeconds: job.retryAfterSeconds, + }; +} + +/** Advances due rotation jobs, one at a time, within a Cloudflare call and time budget. */ +export async function reconcileProxyTokenRotations( + db: Db, + env: ProxyTokenRotationEnv, + limits: Readonly<{ + maxCfCalls?: number; + maxMs?: number; + waitMs?: number; + fetch?: typeof fetch; + }> = {}, +): Promise { + const budget: RotationBudget = { + cfCallsRemaining: limits.maxCfCalls ?? 100, + deadline: Date.now() + (limits.maxMs ?? 25_000), + }; + const summary: RotationRunSummary = { done: 0, pending: 0, failed: 0, retryAfterSeconds: null }; + while (budget.cfCallsRemaining > 0 && Date.now() < budget.deadline) { + const projectId = await claimDueJob(db); + if (!projectId) { + break; + } + const outcome = await runClaimed(db, env, projectId, budget, limits); + if (outcome.status === 'done') { + summary.done += 1; + continue; + } + if (outcome.status === 'failed') { + summary.failed += 1; + continue; + } + summary.pending += 1; + summary.retryAfterSeconds = + summary.retryAfterSeconds === null + ? outcome.retryAfterSeconds + : Math.min(summary.retryAfterSeconds, outcome.retryAfterSeconds); + if (outcome.reason === 'rate-limited' || outcome.reason === 'budget') { + break; + } + } + return summary; +} diff --git a/platform/packages/api/test/integration/helpers.ts b/platform/packages/api/test/integration/helpers.ts index bef0ab931..1a9f63dc3 100644 --- a/platform/packages/api/test/integration/helpers.ts +++ b/platform/packages/api/test/integration/helpers.ts @@ -5,6 +5,7 @@ import { drizzle } from 'drizzle-orm/pglite'; import pg from 'pg'; import { vi } from 'vite-plus/test'; import { signJwt } from '../../src/auth/jwt.ts'; +import type { CronSyncState } from '../../src/cron-scheduler-client.ts'; import type { Db } from '../../src/db.ts'; import { generateId } from '../../src/id.ts'; import { encryptProjectSecret } from '../../src/project-secrets.ts'; @@ -436,6 +437,47 @@ export async function captureQueries( } } +/** Queries captured so far by the active `captureQueries` on `db`. */ +export function capturedQueryCount(db: Db): number { + const capture = queryCaptures.get(db); + if (!capture?.active) { + throw new Error('no query capture is active'); + } + return capture.queries.length; +} + +/** + * Stateful CronScheduler namespace: `/sync` stores the posted state per + * project, `/snapshot` returns it, `/clear` removes it. Records every call. + */ +export function createCronSchedulerMock(): { + namespace: DurableObjectNamespace; + states: Map; + calls: Array<{ name: string; path: string }>; +} { + const states = new Map(); + const calls: Array<{ name: string; path: string }> = []; + const namespace = { + idFromName: (name: string) => ({ toString: () => name }), + get: (id: { toString(): string }) => ({ + fetch: async (request: Request) => { + const name = id.toString(); + const path = new URL(request.url).pathname; + calls.push({ name, path }); + if (path === '/sync') { + states.set(name, (await request.json()) as CronSyncState); + } else if (path === '/clear') { + states.delete(name); + } else if (path === '/snapshot') { + return Response.json({ state: states.get(name) ?? null }); + } + return Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; + return { namespace, states, calls }; +} + export async function setupEnv(): Promise { const kv = new MemoryKVNamespace(); const isrCache = new MemoryKVNamespace(); @@ -1056,6 +1098,7 @@ export type CfApiMocks = { // Dispatch script / container-app endpoints. State-aware defaults are wired // up when overrides are not supplied. Tests can still override individually. getDispatchScript?: (url: string, init?: RequestInit) => Response | Promise; + patchScriptSettings?: (url: string, init?: RequestInit) => Response | Promise; createContainerApp?: (url: string, init?: RequestInit) => Response | Promise; patchContainerApp?: (url: string, init?: RequestInit) => Response | Promise; deleteContainerApp?: (url: string, init?: RequestInit) => Response | Promise; @@ -1367,6 +1410,42 @@ export function mockCfApi(overrides: Partial = {}): MockCfApiHandle }); }; + // PATCH dispatch script settings — the binding list replaces the stored one: + // `inherit` keeps the stored binding of that name (unknown name → 400) and a + // name left out is removed. Echoes the new bindings like Cloudflare does. + const defaultPatchScriptSettings = async (url: string, init?: RequestInit): Promise => { + const scriptName = parseScriptNameFromUrl(url); + const script = scriptName ? state.hostScripts.get(scriptName) : undefined; + if (!script) { + return cfError('workers.api.error.script_not_found', 404, 10007); + } + const part = init?.body instanceof FormData ? init.body.get('settings') : null; + const settings = ( + part instanceof Blob + ? JSON.parse(await part.text()) + : typeof part === 'string' + ? JSON.parse(part) + : null + ) as { bindings?: Array<{ type: string; name: string; [key: string]: unknown }> } | null; + if (!Array.isArray(settings?.bindings)) { + return cfError('settings.bindings is required', 400); + } + const bindings: SandboxMockHostScript['bindings'] = []; + for (const binding of settings.bindings) { + if (binding.type !== 'inherit') { + bindings.push(binding); + continue; + } + const stored = script.bindings.find((entry) => entry.name === binding.name); + if (!stored) { + return cfError(`cannot inherit unknown binding ${binding.name}`, 400); + } + bindings.push(stored); + } + script.bindings = bindings; + return cfSuccess({ bindings }); + }; + // Container app handlers. const defaultListContainerApps = (url: string): Response => { const name = parseAppNameQuery(url); @@ -1513,6 +1592,7 @@ export function mockCfApi(overrides: Partial = {}): MockCfApiHandle listWorkerBindings: defaultListWorkerBindings, deleteWorker: defaultDeleteWorker, getDispatchScript: defaultGetDispatchScript, + patchScriptSettings: defaultPatchScriptSettings, createContainerApp: defaultCreateContainerApp, patchContainerApp: defaultPatchContainerApp, deleteContainerApp: defaultDeleteContainerApp, @@ -1665,6 +1745,9 @@ export function mockCfApi(overrides: Partial = {}): MockCfApiHandle if (method === 'GET' && url.includes('/workers/dispatch/') && url.includes('/bindings')) { return mocks.listWorkerBindings(url, init); } + if (method === 'PATCH' && url.includes('/workers/dispatch/') && url.endsWith('/settings')) { + return mocks.patchScriptSettings!(url, init); + } // GET on a dispatch script (no /bindings, no /secrets) — read the // script's metadata + migration_tag. Must come AFTER /bindings and // /secrets matches because those URLs share the prefix. diff --git a/platform/packages/api/test/integration/proxy-token-rotation.test.ts b/platform/packages/api/test/integration/proxy-token-rotation.test.ts new file mode 100644 index 000000000..fb808a517 --- /dev/null +++ b/platform/packages/api/test/integration/proxy-token-rotation.test.ts @@ -0,0 +1,1127 @@ +import { eq, sql } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { hmacSignState } from '../../src/auth/hmac.ts'; +import { + createDeployment, + markDeploymentRetained, + setActiveDeployment, + updateDeploymentStatus, +} from '../../src/db.ts'; +import app from '../../src/index.ts'; +import { getProjectDeploymentWorkerName, getProjectQueueName } from '../../src/naming.ts'; +import { reconcileProjectCleanupJobs } from '../../src/provisioning/cleanup.ts'; +import { + advanceProxyTokenRotation, + PROXY_TOKEN_ROTATION_WAIT_MS, + proxyTokenRotationTarget, + reconcileProxyTokenRotations, + requestBulkProxyTokenRotations, + requestProxyTokenRotation, + type ProjectRotationOutcome, + type ProxyTokenRotationEnv, + type ProxyTokenRotationReason, +} from '../../src/proxy-token-rotation.ts'; +import { + deriveProxyInternalAuth, + deriveProxyToken, + PROXY_INTERNAL_AUTH_HEADER, + proxyTokenStateKey, + type ProxyInternalRoute, +} from '../../src/proxy-token.ts'; +import { projectCleanupJobs, projects, resources } from '../../src/schema.ts'; +import { + captureQueries, + capturedQueryCount, + cfError, + cfSuccess, + clearTables, + createCronSchedulerMock, + createTestProject, + createTestUser, + JWT_SECRET, + MemoryKVNamespace, + mockCfApi, + setupEnv, + teardownEnv, + type CfApiMocks, + type MockCfApiHandle, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +const MANIFEST = { + version: 3, + bindings: {}, + schedules: [{ cron: '*/5 * * * *', filePath: 'crons/tick.ts' }], + queues: [{ name: 'emails', bindingName: 'QUEUE_EMAILS' }], +}; + +/** Ordered log of Cloudflare calls (`GET