From 0e9dd248ac9852b6932a3bf113bdc280897e66db Mon Sep 17 00:00:00 2001 From: codexu <461229187@qq.com> Date: Wed, 19 Aug 2026 19:19:06 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E6=89=98=E7=AE=A1=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=8D=8F=E4=BD=9C?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- README.md | 13 +- .../drizzle/0002_amazing_proemial_gods.sql | 12 + apps/server/drizzle/0003_amused_tag.sql | 10 + .../drizzle/0004_workspace_collaboration.sql | 53 + apps/server/drizzle/meta/0002_snapshot.json | 11453 +++++++++++++++ apps/server/drizzle/meta/0003_snapshot.json | 11522 ++++++++++++++++ apps/server/drizzle/meta/_journal.json | 23 +- apps/server/scripts/self-test.ts | 15 +- apps/server/src/app.ts | 55 +- apps/server/src/auth/service.ts | 35 +- apps/server/src/blobs/service.ts | 14 +- apps/server/src/config.ts | 41 +- .../src/database/migration-compatibility.ts | 5 + apps/server/src/database/schema.ts | 80 +- apps/server/src/deployment/capabilities.ts | 104 +- apps/server/src/deployment/service.ts | 9 +- apps/server/src/durable-sync/service.ts | 322 +- apps/server/src/identity/email-service.ts | 2 +- apps/server/src/installation/service.ts | 75 +- apps/server/src/invitations/service.ts | 6 +- apps/server/src/maintenance/service.ts | 65 +- apps/server/src/observability/metrics.ts | 39 +- apps/server/src/routes/api-schemas.ts | 2 +- apps/server/src/routes/blobs.ts | 12 +- apps/server/src/routes/capabilities.ts | 10 +- apps/server/src/routes/events.ts | 181 +- apps/server/src/routes/installation.ts | 4 - apps/server/src/routes/sync.ts | 223 +- apps/server/src/routes/web-admin.ts | 9 +- apps/server/src/routes/web-auth.ts | 26 +- apps/server/src/routes/web-dashboard.ts | 26 + .../src/routes/workspace-collaboration.ts | 164 + apps/server/src/routes/workspaces.ts | 29 +- apps/server/src/server.ts | 121 +- apps/server/src/services.ts | 2 + apps/server/src/sync/notifier.ts | 6 + apps/server/src/sync/types.ts | 15 +- apps/server/src/usage/service.ts | 79 +- apps/server/src/workspaces/capabilities.ts | 43 + .../src/workspaces/collaboration-service.ts | 337 + apps/server/src/workspaces/service.ts | 169 +- ...otegen-client-behavior.integration.test.ts | 103 +- apps/web/README.md | 2 +- apps/web/app/account/accept-invite/page.tsx | 5 + apps/web/app/account/reset-password/page.tsx | 5 + apps/web/app/account/verify-email/page.tsx | 5 + apps/web/app/globals.css | 1 - apps/web/app/icon.svg | 4 + apps/web/app/layout.tsx | 2 +- apps/web/app/operations/page.tsx | 5 - apps/web/app/service/page.tsx | 108 + apps/web/components/account-portal.tsx | 654 +- apps/web/components/admin-shell.tsx | 91 +- apps/web/components/device-connection.tsx | 97 +- apps/web/components/experimental-center.tsx | 7 +- apps/web/components/installation-guide.tsx | 482 +- apps/web/components/invitation-acceptance.tsx | 81 + apps/web/components/locale-provider.tsx | 2 +- apps/web/components/notegen-brand.tsx | 32 + apps/web/components/operations-center.tsx | 49 +- apps/web/components/operations-portal.tsx | 456 - apps/web/components/operations-shell.tsx | 249 - apps/web/components/site-chrome.tsx | 160 + apps/web/lib/api.ts | 15 +- docs/account-service/README.md | 23 +- docs/client-protocol.md | 24 +- docs/notegen-integration.md | 24 +- docs/operations.md | 18 +- docs/production-checklist.md | 69 + docs/protocol-v1.md | 71 + docs/self-test.md | 21 +- docs/sync-server-spec.md | 2 + packages/contracts/src/index.ts | 55 +- 74 files changed, 26392 insertions(+), 1944 deletions(-) create mode 100644 apps/server/drizzle/0002_amazing_proemial_gods.sql create mode 100644 apps/server/drizzle/0003_amused_tag.sql create mode 100644 apps/server/drizzle/0004_workspace_collaboration.sql create mode 100644 apps/server/drizzle/meta/0002_snapshot.json create mode 100644 apps/server/drizzle/meta/0003_snapshot.json create mode 100644 apps/server/src/routes/workspace-collaboration.ts create mode 100644 apps/server/src/workspaces/capabilities.ts create mode 100644 apps/server/src/workspaces/collaboration-service.ts create mode 100644 apps/web/app/account/accept-invite/page.tsx create mode 100644 apps/web/app/account/reset-password/page.tsx create mode 100644 apps/web/app/account/verify-email/page.tsx create mode 100644 apps/web/app/icon.svg delete mode 100644 apps/web/app/operations/page.tsx create mode 100644 apps/web/app/service/page.tsx create mode 100644 apps/web/components/invitation-acceptance.tsx create mode 100644 apps/web/components/notegen-brand.tsx delete mode 100644 apps/web/components/operations-portal.tsx delete mode 100644 apps/web/components/operations-shell.tsx create mode 100644 apps/web/components/site-chrome.tsx create mode 100644 docs/production-checklist.md create mode 100644 docs/protocol-v1.md diff --git a/.env.example b/.env.example index 50dce35..20db803 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ PUBLIC_BASE_URL=https://sync.example.com # 登录令牌与数据库内敏感配置的加密密钥,至少 32 个随机字符;上线后不要更换。 AUTH_SECRET=replace-with-at-least-32-random-characters -# 部署模式、服务名称、管理员账号和注册策略在首次打开 Web 页面时设置; +# 服务名称、管理员账号和注册策略在首次打开 Web 页面时设置; # 业务限额、保留周期和邮件配置使用安全默认值,之后在 Web 管理后台维护。 +# SMTP 默认关闭且不是安装依赖;需要邀请邮件或密码找回时再由管理员配置。 # 空实例无需安装密钥,请先完成 Web 安装,再开放公网入口。 diff --git a/README.md b/README.md index bdf003d..a32b4d3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ # NoteGen Sync Server -NoteGen Sync Server 是 NoteGen 客户端使用的同步服务,可用于自行部署,也可用于运营官方托管服务。 +NoteGen Sync Server 是 NoteGen 客户端使用的免费同步服务。官方公共测试服务和用户自行部署使用同一套独立实例运行方式。 项目包含同步 API 和账号管理 Web。Web 负责注册、登录、设备关联、同步统计和账号管理,不包含 Markdown 编辑器、AI 功能或多人协作功能。 +## 项目定位 + +- 项目免费且不以商业化为目标,不提供付费订阅、套餐或商业 SLA。 +- 官方只提供供社区体验的公共测试实例;它与用户自行部署使用相同代码和运行方式。 +- 每个实例由自己的管理员负责注册策略、邀请、数据保留、备份和滥用处理。 +- 邮件不是基础依赖。SMTP 默认关闭;未配置时仍可使用账号密码和一次性邀请链接。 +- 客户端应继续保留本地数据,公共测试实例不应被视为唯一备份。 + ## Monorepo 结构 ```text @@ -20,6 +28,7 @@ apps/web Next.js + shadcn/ui 账号管理页面 - [NoteGen 客户端接入协议](docs/client-protocol.md) - [NoteGen 接入与配置同步体验](docs/notegen-integration.md) - [部署后的个人验收用例](docs/self-test.md) +- [公共测试实例上线清单](docs/production-checklist.md) - [账号服务开发计划总览](docs/account-service/README.md) ## 已确定技术栈 @@ -70,7 +79,7 @@ createuser notegen createdb -O notegen notegen ``` -复制并填写本地配置。`DATABASE_URL`、`AUTH_SECRET` 与 `SETUP_TOKEN` 必须设置为自己的值: +复制并填写本地配置。`DATABASE_URL`、`AUTH_SECRET` 与 `PUBLIC_BASE_URL` 必须设置为自己的值: ```bash cp .env.example .env diff --git a/apps/server/drizzle/0002_amazing_proemial_gods.sql b/apps/server/drizzle/0002_amazing_proemial_gods.sql new file mode 100644 index 0000000..caf876a --- /dev/null +++ b/apps/server/drizzle/0002_amazing_proemial_gods.sql @@ -0,0 +1,12 @@ +CREATE TABLE "sync_device_cursors" ( + "workspace_id" uuid NOT NULL, + "device_id" uuid NOT NULL, + "acknowledged_sequence" bigint DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sync_device_cursors_workspace_id_device_id_pk" PRIMARY KEY("workspace_id","device_id") +); +--> statement-breakpoint +ALTER TABLE "sync_commands" ADD COLUMN "sync_epoch" uuid;--> statement-breakpoint +ALTER TABLE "sync_device_cursors" ADD CONSTRAINT "sync_device_cursors_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_device_cursors" ADD CONSTRAINT "sync_device_cursors_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "sync_device_cursors_updated_idx" ON "sync_device_cursors" USING btree ("workspace_id","updated_at"); \ No newline at end of file diff --git a/apps/server/drizzle/0003_amused_tag.sql b/apps/server/drizzle/0003_amused_tag.sql new file mode 100644 index 0000000..ecf1806 --- /dev/null +++ b/apps/server/drizzle/0003_amused_tag.sql @@ -0,0 +1,10 @@ +CREATE TABLE "rate_limit_buckets" ( + "scope" text NOT NULL, + "rate_key" text NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "hits" integer DEFAULT 0 NOT NULL, + CONSTRAINT "rate_limit_buckets_scope_rate_key_window_start_pk" PRIMARY KEY("scope","rate_key","window_start") +); +--> statement-breakpoint +CREATE INDEX "rate_limit_buckets_expiry_idx" ON "rate_limit_buckets" USING btree ("expires_at"); \ No newline at end of file diff --git a/apps/server/drizzle/0004_workspace_collaboration.sql b/apps/server/drizzle/0004_workspace_collaboration.sql new file mode 100644 index 0000000..ef9ba18 --- /dev/null +++ b/apps/server/drizzle/0004_workspace_collaboration.sql @@ -0,0 +1,53 @@ +CREATE TYPE "public"."workspace_type" AS ENUM('account-data', 'library'); +CREATE TYPE "public"."workspace_member_role" AS ENUM('viewer', 'editor', 'manager'); +CREATE TYPE "public"."workspace_invitation_kind" AS ENUM('account', 'link'); +CREATE TYPE "public"."workspace_invitation_status" AS ENUM('pending', 'accepted', 'revoked', 'expired'); +ALTER TYPE "public"."object_kind" ADD VALUE 'message'; + +ALTER TABLE "workspaces" ADD COLUMN "workspace_type" "workspace_type" DEFAULT 'library' NOT NULL; +UPDATE "workspaces" SET "workspace_type" = 'account-data' WHERE "is_default" = true; + +CREATE TABLE "workspace_members" ( + "workspace_id" uuid NOT NULL, + "account_id" uuid NOT NULL, + "role" "workspace_member_role" NOT NULL, + "capabilities" text[] NOT NULL, + "invited_by_account_id" uuid NOT NULL, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "workspace_members_workspace_id_account_id_pk" PRIMARY KEY("workspace_id","account_id") +); + +CREATE TABLE "workspace_invitations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "kind" "workspace_invitation_kind" NOT NULL, + "invitee_account_id" uuid, + "token_hash" text, + "token_hint" text, + "role" "workspace_member_role" NOT NULL, + "capabilities" text[] NOT NULL, + "status" "workspace_invitation_status" DEFAULT 'pending' NOT NULL, + "invited_by_account_id" uuid NOT NULL, + "accepted_by_account_id" uuid, + "expires_at" timestamp with time zone NOT NULL, + "accepted_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade; +ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade; +ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_invited_by_account_id_accounts_id_fk" FOREIGN KEY ("invited_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE restrict; +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade; +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_invitee_account_id_accounts_id_fk" FOREIGN KEY ("invitee_account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade; +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_invited_by_account_id_accounts_id_fk" FOREIGN KEY ("invited_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE restrict; +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_accepted_by_account_id_accounts_id_fk" FOREIGN KEY ("accepted_by_account_id") REFERENCES "public"."accounts"("id") ON DELETE set null; + +CREATE INDEX "workspace_members_account_idx" ON "workspace_members" USING btree ("account_id","updated_at"); +CREATE INDEX "workspace_invitations_workspace_idx" ON "workspace_invitations" USING btree ("workspace_id","status","created_at"); +CREATE INDEX "workspace_invitations_invitee_idx" ON "workspace_invitations" USING btree ("invitee_account_id","status","created_at"); +CREATE UNIQUE INDEX "workspace_invitations_token_unique" ON "workspace_invitations" USING btree ("token_hash") WHERE "token_hash" is not null; +CREATE UNIQUE INDEX "workspace_invitations_pending_account_unique" ON "workspace_invitations" USING btree ("workspace_id","invitee_account_id") WHERE "kind" = 'account' and "status" = 'pending'; +CREATE UNIQUE INDEX "workspaces_account_data_unique" ON "workspaces" USING btree ("account_id") WHERE "workspace_type" = 'account-data' and "deleted_at" is null; diff --git a/apps/server/drizzle/meta/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..cb98877 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,11453 @@ +{ + "id": "a76dbd30-2474-48bd-adb7-f17389833a92", + "prevId": "03aca076-eb46-47a7-b0c4-a2bc8dfb9ee6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "identity_id": { + "name": "identity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "account_action_token_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_key_id": { + "name": "token_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_normalized": { + "name": "target_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "requested_ip_hash": { + "name": "requested_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_account_idx": { + "name": "account_action_tokens_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_account_id_accounts_id_fk": { + "name": "account_action_tokens_account_id_accounts_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_action_tokens_identity_id_account_identities_id_fk": { + "name": "account_action_tokens_identity_id_account_identities_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "account_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_deletion_cases": { + "name": "account_deletion_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "deletion_case_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "cancel_until": { + "name": "cancel_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "purge_after": { + "name": "purge_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_credential_hash": { + "name": "cancel_credential_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purge_manifest_ref": { + "name": "purge_manifest_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purge_manifest_hash": { + "name": "purge_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purge_manifest": { + "name": "purge_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_deletion_cases_active_account_unique": { + "name": "account_deletion_cases_active_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_deletion_cases\".\"account_id\" is not null and \"account_deletion_cases\".\"status\" in ('requested', 'cooling_off', 'scheduled', 'held', 'purging')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_deletion_cases_active_subject_unique": { + "name": "account_deletion_cases_active_subject_unique", + "columns": [ + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_deletion_cases\".\"status\" in ('requested', 'cooling_off', 'scheduled', 'held', 'purging')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_deletion_fences": { + "name": "account_deletion_fences", + "schema": "", + "columns": { + "account_uuid": { + "name": "account_uuid", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "deletion_fence_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "hold_revision": { + "name": "hold_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "blocks_domain_writes": { + "name": "blocks_domain_writes", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_identities": { + "name": "account_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "account_identity_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_identifier": { + "name": "normalized_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_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": { + "account_identities_active_unique": { + "name": "account_identities_active_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_identities\".\"disabled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_identities_account_idx": { + "name": "account_identities_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_identities_account_id_accounts_id_fk": { + "name": "account_identities_account_id_accounts_id_fk", + "tableFrom": "account_identities", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_login_claim_conflicts": { + "name": "account_login_claim_conflicts", + "schema": "", + "columns": { + "normalized_login_key": { + "name": "normalized_login_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "candidate_account_id": { + "name": "candidate_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "candidate_identity_id": { + "name": "candidate_identity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "candidate_kind": { + "name": "candidate_kind", + "type": "account_login_claim_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'quarantined'" + }, + "resolution_ref": { + "name": "resolution_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "account_login_claim_conflicts_status_idx": { + "name": "account_login_claim_conflicts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_login_claim_conflicts_candidate_account_id_accounts_id_fk": { + "name": "account_login_claim_conflicts_candidate_account_id_accounts_id_fk", + "tableFrom": "account_login_claim_conflicts", + "tableTo": "accounts", + "columnsFrom": [ + "candidate_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_login_claim_conflicts_candidate_identity_id_account_identities_id_fk": { + "name": "account_login_claim_conflicts_candidate_identity_id_account_identities_id_fk", + "tableFrom": "account_login_claim_conflicts", + "tableTo": "account_identities", + "columnsFrom": [ + "candidate_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_login_claim_conflicts_normalized_login_key_candidate_account_id_candidate_kind_pk": { + "name": "account_login_claim_conflicts_normalized_login_key_candidate_account_id_candidate_kind_pk", + "columns": [ + "normalized_login_key", + "candidate_account_id", + "candidate_kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_login_claims": { + "name": "account_login_claims", + "schema": "", + "columns": { + "normalized_login_key": { + "name": "normalized_login_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "account_login_claim_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reusable_after": { + "name": "reusable_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_login_claims_account_id_accounts_id_fk": { + "name": "account_login_claims_account_id_accounts_id_fk", + "tableFrom": "account_login_claims", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "account_login_claims_identity_id_account_identities_id_fk": { + "name": "account_login_claims_identity_id_account_identities_id_fk", + "tableFrom": "account_login_claims", + "tableTo": "account_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_service_audit_events": { + "name": "account_service_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_service_audit_events_actor_idx": { + "name": "account_service_audit_events_actor_idx", + "columns": [ + { + "expression": "actor_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_service_audit_events_target_idx": { + "name": "account_service_audit_events_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_service_audit_events_actor_type_check": { + "name": "account_service_audit_events_actor_type_check", + "value": "\"account_service_audit_events\".\"actor_type\" in ('account', 'staff', 'system', 'webhook')" + }, + "account_service_audit_events_action_check": { + "name": "account_service_audit_events_action_check", + "value": "length(\"account_service_audit_events\".\"action\") > 0" + }, + "account_service_audit_events_target_type_check": { + "name": "account_service_audit_events_target_type_check", + "value": "length(\"account_service_audit_events\".\"target_type\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.account_subscriptions": { + "name": "account_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_version_id": { + "name": "plan_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_revision": { + "name": "provider_revision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "grace_ends_at": { + "name": "grace_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "account_subscriptions_provider_subscription_unique": { + "name": "account_subscriptions_provider_subscription_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_subscriptions_current_account_unique": { + "name": "account_subscriptions_current_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_subscriptions\".\"account_id\" is not null and \"account_subscriptions\".\"is_current\"", + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_subscriptions_account_status_idx": { + "name": "account_subscriptions_account_status_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_subscriptions_account_id_accounts_id_fk": { + "name": "account_subscriptions_account_id_accounts_id_fk", + "tableFrom": "account_subscriptions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "account_subscriptions_plan_version_id_billing_plan_versions_id_fk": { + "name": "account_subscriptions_plan_version_id_billing_plan_versions_id_fk", + "tableFrom": "account_subscriptions", + "tableTo": "billing_plan_versions", + "columnsFrom": [ + "plan_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_usage": { + "name": "account_usage", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "active_object_bytes": { + "name": "active_object_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_crdt_bytes": { + "name": "active_crdt_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_blob_bytes": { + "name": "active_blob_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "reserved_blob_bytes": { + "name": "reserved_blob_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "retained_bytes": { + "name": "retained_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_objects": { + "name": "active_objects", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_devices": { + "name": "active_devices", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_workspaces": { + "name": "active_workspaces", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_usage_account_id_accounts_id_fk": { + "name": "account_usage_account_id_accounts_id_fk", + "tableFrom": "account_usage", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled_at": { + "name": "totp_enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "identity_state": { + "name": "identity_state", + "type": "account_identity_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'legacy_migration'" + }, + "credential_epoch": { + "name": "credential_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "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": { + "accounts_login_unique": { + "name": "accounts_login_unique", + "columns": [ + { + "expression": "lower(\"login\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_logs": { + "name": "admin_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_logs_actor_idx": { + "name": "admin_audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_logs_created_idx": { + "name": "admin_audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_logs_actor_account_id_accounts_id_fk": { + "name": "admin_audit_logs_actor_account_id_accounts_id_fk", + "tableFrom": "admin_audit_logs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_backups": { + "name": "admin_backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "admin_backups_filename_unique": { + "name": "admin_backups_filename_unique", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_backups_job_id_admin_jobs_id_fk": { + "name": "admin_backups_job_id_admin_jobs_id_fk", + "tableFrom": "admin_backups", + "tableTo": "admin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_jobs": { + "name": "admin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_jobs_created_idx": { + "name": "admin_jobs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_jobs_status_idx": { + "name": "admin_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_jobs_actor_account_id_accounts_id_fk": { + "name": "admin_jobs_actor_account_id_accounts_id_fk", + "tableFrom": "admin_jobs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_test_objects": { + "name": "admin_test_objects", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_test_objects_created_idx": { + "name": "admin_test_objects_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_test_objects_workspace_id_workspaces_id_fk": { + "name": "admin_test_objects_workspace_id_workspaces_id_fk", + "tableFrom": "admin_test_objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admin_test_objects_created_by_account_id_accounts_id_fk": { + "name": "admin_test_objects_created_by_account_id_accounts_id_fk", + "tableFrom": "admin_test_objects", + "tableTo": "accounts", + "columnsFrom": [ + "created_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "admin_test_objects_workspace_id_object_id_pk": { + "name": "admin_test_objects_workspace_id_object_id_pk", + "columns": [ + "workspace_id", + "object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_jobs": { + "name": "background_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_generation": { + "name": "queue_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "min_handler_version": { + "name": "min_handler_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_account_id": { + "name": "target_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "background_jobs_idempotency_unique": { + "name": "background_jobs_idempotency_unique", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_jobs_claim_idx": { + "name": "background_jobs_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_jobs_actor_account_id_accounts_id_fk": { + "name": "background_jobs_actor_account_id_accounts_id_fk", + "tableFrom": "background_jobs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "background_jobs_target_account_id_accounts_id_fk": { + "name": "background_jobs_target_account_id_accounts_id_fk", + "tableFrom": "background_jobs", + "tableTo": "accounts", + "columnsFrom": [ + "target_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "background_jobs_payload_version_check": { + "name": "background_jobs_payload_version_check", + "value": "\"background_jobs\".\"payload_version\" > 0" + }, + "background_jobs_queue_generation_check": { + "name": "background_jobs_queue_generation_check", + "value": "\"background_jobs\".\"queue_generation\" > 0" + }, + "background_jobs_min_handler_version_check": { + "name": "background_jobs_min_handler_version_check", + "value": "\"background_jobs\".\"min_handler_version\" > 0" + }, + "background_jobs_attempt_check": { + "name": "background_jobs_attempt_check", + "value": "\"background_jobs\".\"attempt\" >= 0" + }, + "background_jobs_max_attempts_check": { + "name": "background_jobs_max_attempts_check", + "value": "\"background_jobs\".\"max_attempts\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.backup_artifacts": { + "name": "backup_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_run_id": { + "name": "backup_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backup_artifacts_run_path_unique": { + "name": "backup_artifacts_run_path_unique", + "columns": [ + { + "expression": "backup_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relative_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backup_artifacts_run_idx": { + "name": "backup_artifacts_run_idx", + "columns": [ + { + "expression": "backup_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "backup_artifacts_backup_run_id_backup_runs_id_fk": { + "name": "backup_artifacts_backup_run_id_backup_runs_id_fk", + "tableFrom": "backup_artifacts", + "tableTo": "backup_runs", + "columnsFrom": [ + "backup_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "backup_artifacts_size_check": { + "name": "backup_artifacts_size_check", + "value": "\"backup_artifacts\".\"size\" >= 0" + }, + "backup_artifacts_path_check": { + "name": "backup_artifacts_path_check", + "value": "length(\"backup_artifacts\".\"relative_path\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.backup_policies": { + "name": "backup_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_ref": { + "name": "target_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retention": { + "name": "retention", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encryption_key_id": { + "name": "encryption_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_runs": { + "name": "backup_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "generation": { + "name": "generation", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "backup_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "snapshot_at": { + "name": "snapshot_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "manifest_ref": { + "name": "manifest_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_bytes": { + "name": "database_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "blob_count": { + "name": "blob_count", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "blob_bytes": { + "name": "blob_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "backup_runs_generation_unique": { + "name": "backup_runs_generation_unique", + "columns": [ + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backup_runs_status_idx": { + "name": "backup_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "backup_runs_policy_id_backup_policies_id_fk": { + "name": "backup_runs_policy_id_backup_policies_id_fk", + "tableFrom": "backup_runs", + "tableTo": "backup_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_account_states": { + "name": "billing_account_states", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "current_subscription_id": { + "name": "current_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "purchase_intent_id": { + "name": "purchase_intent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_account_states_account_id_accounts_id_fk": { + "name": "billing_account_states_account_id_accounts_id_fk", + "tableFrom": "billing_account_states", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_account_states_current_subscription_id_account_subscriptions_id_fk": { + "name": "billing_account_states_current_subscription_id_account_subscriptions_id_fk", + "tableFrom": "billing_account_states", + "tableTo": "account_subscriptions", + "columnsFrom": [ + "current_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "billing_account_states_purchase_intent_id_billing_checkout_sessions_id_fk": { + "name": "billing_account_states_purchase_intent_id_billing_checkout_sessions_id_fk", + "tableFrom": "billing_account_states", + "tableTo": "billing_checkout_sessions", + "columnsFrom": [ + "purchase_intent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_account_states_current_or_intent_check": { + "name": "billing_account_states_current_or_intent_check", + "value": "not (\"billing_account_states\".\"current_subscription_id\" is not null and \"billing_account_states\".\"purchase_intent_id\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.billing_checkout_sessions": { + "name": "billing_checkout_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_checkout_id": { + "name": "provider_checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "price_mapping_id": { + "name": "price_mapping_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_version_id": { + "name": "plan_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "billing_checkout_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_provider'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "billing_checkout_sessions_account_idempotency_unique": { + "name": "billing_checkout_sessions_account_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_sessions_provider_checkout_unique": { + "name": "billing_checkout_sessions_provider_checkout_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_checkout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_sessions\".\"provider_checkout_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_sessions_active_account_unique": { + "name": "billing_checkout_sessions_active_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_sessions\".\"status\" in ('pending_provider','open','completed','reconciling')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_checkout_sessions_account_id_accounts_id_fk": { + "name": "billing_checkout_sessions_account_id_accounts_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_checkout_sessions_subscription_id_account_subscriptions_id_fk": { + "name": "billing_checkout_sessions_subscription_id_account_subscriptions_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "account_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "billing_checkout_sessions_price_mapping_id_billing_price_mappings_id_fk": { + "name": "billing_checkout_sessions_price_mapping_id_billing_price_mappings_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "billing_price_mappings", + "columnsFrom": [ + "price_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_sessions_plan_version_id_billing_plan_versions_id_fk": { + "name": "billing_checkout_sessions_plan_version_id_billing_plan_versions_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "billing_plan_versions", + "columnsFrom": [ + "plan_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_customers": { + "name": "billing_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "billing_customers_provider_customer_unique": { + "name": "billing_customers_provider_customer_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_customers_account_unique": { + "name": "billing_customers_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_customers\".\"account_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_customers_account_id_accounts_id_fk": { + "name": "billing_customers_account_id_accounts_id_fk", + "tableFrom": "billing_customers", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_plan_versions": { + "name": "billing_plan_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_key": { + "name": "plan_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "interval": { + "name": "interval", + "type": "billing_plan_interval", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entitlement_schema_version": { + "name": "entitlement_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlements": { + "name": "entitlements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "active_from": { + "name": "active_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "retired_at": { + "name": "retired_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": { + "billing_plan_versions_key_version_unique": { + "name": "billing_plan_versions_key_version_unique", + "columns": [ + { + "expression": "plan_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_plan_versions_amount_check": { + "name": "billing_plan_versions_amount_check", + "value": "\"billing_plan_versions\".\"amount_minor\" >= 0" + }, + "billing_plan_versions_version_check": { + "name": "billing_plan_versions_version_check", + "value": "\"billing_plan_versions\".\"version\" > 0" + }, + "billing_plan_versions_schema_check": { + "name": "billing_plan_versions_schema_check", + "value": "\"billing_plan_versions\".\"entitlement_schema_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_price_mappings": { + "name": "billing_price_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_version_id": { + "name": "plan_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_environment": { + "name": "provider_environment", + "type": "billing_provider_environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_price_id": { + "name": "provider_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval": { + "name": "interval", + "type": "billing_plan_interval", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_price_mappings_provider_price_unique": { + "name": "billing_price_mappings_provider_price_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_price_mappings_plan_provider_unique": { + "name": "billing_price_mappings_plan_provider_unique", + "columns": [ + { + "expression": "plan_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "interval", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_price_mappings_plan_version_id_billing_plan_versions_id_fk": { + "name": "billing_price_mappings_plan_version_id_billing_plan_versions_id_fk", + "tableFrom": "billing_price_mappings", + "tableTo": "billing_plan_versions", + "columnsFrom": [ + "plan_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_verified_at": { + "name": "signature_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "payload_redacted": { + "name": "payload_redacted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "billing_webhook_event_status", + "typeSchema": "public", + "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": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_webhook_events_claim_idx": { + "name": "billing_webhook_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_webhook_events_provider_provider_event_id_pk": { + "name": "billing_webhook_events_provider_provider_event_id_pk", + "columns": [ + "provider", + "provider_event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob_upload_parts": { + "name": "blob_upload_parts", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "part_number": { + "name": "part_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "blob_upload_parts_upload_id_blob_uploads_id_fk": { + "name": "blob_upload_parts_upload_id_blob_uploads_id_fk", + "tableFrom": "blob_upload_parts", + "tableTo": "blob_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blob_upload_parts_upload_id_part_number_pk": { + "name": "blob_upload_parts_upload_id_part_number_pk", + "columns": [ + "upload_id", + "part_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob_uploads": { + "name": "blob_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_id": { + "name": "blob_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_reservation_id": { + "name": "usage_reservation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sync_epoch": { + "name": "sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expected_size": { + "name": "expected_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "received_size": { + "name": "received_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completing_at": { + "name": "completing_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "blob_uploads_workspace_blob_unique": { + "name": "blob_uploads_workspace_blob_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blob_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blob_uploads_workspace_blob_idx": { + "name": "blob_uploads_workspace_blob_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blob_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blob_uploads_expiry_idx": { + "name": "blob_uploads_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blob_uploads_usage_reservation_unique": { + "name": "blob_uploads_usage_reservation_unique", + "columns": [ + { + "expression": "usage_reservation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"blob_uploads\".\"usage_reservation_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blob_uploads_workspace_id_workspaces_id_fk": { + "name": "blob_uploads_workspace_id_workspaces_id_fk", + "tableFrom": "blob_uploads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blob_uploads_usage_reservation_id_usage_reservations_id_fk": { + "name": "blob_uploads_usage_reservation_id_usage_reservations_id_fk", + "tableFrom": "blob_uploads", + "tableTo": "usage_reservations", + "columnsFrom": [ + "usage_reservation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blobs": { + "name": "blobs", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_id": { + "name": "blob_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "blob_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_referenced_at": { + "name": "last_referenced_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": { + "blobs_storage_key_unique": { + "name": "blobs_storage_key_unique", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blobs_gc_idx": { + "name": "blobs_gc_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_referenced_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blobs_workspace_id_workspaces_id_fk": { + "name": "blobs_workspace_id_workspaces_id_fk", + "tableFrom": "blobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blobs_workspace_id_blob_id_pk": { + "name": "blobs_workspace_id_blob_id_pk", + "columns": [ + "workspace_id", + "blob_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bootstrap_credentials": { + "name": "bootstrap_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "bootstrap_credential_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_key_id": { + "name": "token_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hint": { + "name": "token_hint", + "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 + }, + "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": { + "bootstrap_credentials_token_hash_unique": { + "name": "bootstrap_credentials_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bootstrap_credentials_active_idx": { + "name": "bootstrap_credentials_active_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bootstrap_sessions": { + "name": "bootstrap_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_sequence": { + "name": "snapshot_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bootstrap_sessions_expiry_idx": { + "name": "bootstrap_sessions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bootstrap_sessions_device_idx": { + "name": "bootstrap_sessions_device_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bootstrap_sessions_workspace_id_workspaces_id_fk": { + "name": "bootstrap_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "bootstrap_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bootstrap_sessions_device_id_devices_id_fk": { + "name": "bootstrap_sessions_device_id_devices_id_fk", + "tableFrom": "bootstrap_sessions", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_consumptions": { + "name": "challenge_consumptions", + "schema": "", + "columns": { + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "digest_key_id": { + "name": "digest_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expected_hostname": { + "name": "expected_hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_claims_hash": { + "name": "verified_claims_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changes": { + "name": "changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "change_type": { + "name": "change_type", + "type": "change_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "changes_workspace_sequence_unique": { + "name": "changes_workspace_sequence_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changes_workspace_object_idx": { + "name": "changes_workspace_object_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changes_created_idx": { + "name": "changes_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "changes_workspace_id_workspaces_id_fk": { + "name": "changes_workspace_id_workspaces_id_fk", + "tableFrom": "changes", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "changes_source_device_id_devices_id_fk": { + "name": "changes_source_device_id_devices_id_fk", + "tableFrom": "changes", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_requests": { + "name": "data_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_idempotency_key": { + "name": "client_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "data_request_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "request_channel": { + "name": "request_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_ref": { + "name": "result_ref", + "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": { + "data_requests_subject_idempotency_unique": { + "name": "data_requests_subject_idempotency_unique", + "columns": [ + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_requests_status_idx": { + "name": "data_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_requests_account_id_accounts_id_fk": { + "name": "data_requests_account_id_accounts_id_fk", + "tableFrom": "data_requests", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_case_steps": { + "name": "deletion_case_steps", + "schema": "", + "columns": { + "deletion_case_id": { + "name": "deletion_case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "deletion_step_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_case_steps_deletion_case_id_account_deletion_cases_id_fk": { + "name": "deletion_case_steps_deletion_case_id_account_deletion_cases_id_fk", + "tableFrom": "deletion_case_steps", + "tableTo": "account_deletion_cases", + "columnsFrom": [ + "deletion_case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deletion_case_steps_deletion_case_id_handler_pk": { + "name": "deletion_case_steps_deletion_case_id_handler_pk", + "columns": [ + "deletion_case_id", + "handler" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_ledger": { + "name": "deletion_ledger", + "schema": "", + "columns": { + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "hash_key_id": { + "name": "hash_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deletion_case_id": { + "name": "deletion_case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "minimum_backup_generation": { + "name": "minimum_backup_generation", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "minimum_database_lsn": { + "name": "minimum_database_lsn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_ledger_outbox": { + "name": "deletion_ledger_outbox", + "schema": "", + "columns": { + "deletion_case_id": { + "name": "deletion_case_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "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()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deletion_ledger_outbox_claim_idx": { + "name": "deletion_ledger_outbox_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deletion_ledger_outbox_deletion_case_id_account_deletion_cases_id_fk": { + "name": "deletion_ledger_outbox_deletion_case_id_account_deletion_cases_id_fk", + "tableFrom": "deletion_ledger_outbox", + "tableTo": "account_deletion_cases", + "columnsFrom": [ + "deletion_case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deletion_ledger_outbox_idempotency_key_unique": { + "name": "deletion_ledger_outbox_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "deployment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "registration_policy": { + "name": "registration_policy", + "type": "registration_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "self_hosted_lifecycle": { + "name": "self_hosted_lifecycle", + "type": "self_hosted_lifecycle", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "admin_repair_required": { + "name": "admin_repair_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "instance_auth_epoch": { + "name": "instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "token_not_before": { + "name": "token_not_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auth_epoch_enforced": { + "name": "auth_epoch_enforced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "runtime_configuration": { + "name": "runtime_configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "configuration_revision": { + "name": "configuration_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "1" + }, + "initialized_at": { + "name": "initialized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "initialized_by_account_id": { + "name": "initialized_by_account_id", + "type": "uuid", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_authorizations": { + "name": "device_authorizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_public_key": { + "name": "encryption_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "device_authorization_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "approved_credential_epoch": { + "name": "approved_credential_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "approved_instance_auth_epoch": { + "name": "approved_instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "approved_issued_at": { + "name": "approved_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_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": { + "device_authorizations_device_code_unique": { + "name": "device_authorizations_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_authorizations_user_code_unique": { + "name": "device_authorizations_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_authorizations_expiry_idx": { + "name": "device_authorizations_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_authorizations_account_idx": { + "name": "device_authorizations_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_authorizations_account_id_accounts_id_fk": { + "name": "device_authorizations_account_id_accounts_id_fk", + "tableFrom": "device_authorizations", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_cursors": { + "name": "device_cursors", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "acknowledged_sequence": { + "name": "acknowledged_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_cursors_workspace_id_workspaces_id_fk": { + "name": "device_cursors_workspace_id_workspaces_id_fk", + "tableFrom": "device_cursors", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_cursors_device_id_devices_id_fk": { + "name": "device_cursors_device_id_devices_id_fk", + "tableFrom": "device_cursors", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_cursors_workspace_id_device_id_pk": { + "name": "device_cursors_workspace_id_device_id_pk", + "columns": [ + "workspace_id", + "device_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_pairings": { + "name": "device_pairings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credential_epoch": { + "name": "credential_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "instance_auth_epoch": { + "name": "instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_pairings_token_hash_unique": { + "name": "device_pairings_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_pairings_account_idx": { + "name": "device_pairings_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_pairings_expiry_idx": { + "name": "device_pairings_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_pairings_account_id_accounts_id_fk": { + "name": "device_pairings_account_id_accounts_id_fk", + "tableFrom": "device_pairings", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_public_key": { + "name": "encryption_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_account_idx": { + "name": "devices_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_account_id_accounts_id_fk": { + "name": "devices_account_id_accounts_id_fk", + "tableFrom": "devices", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlement_grants": { + "name": "entitlement_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "entitlement_grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlements": { + "name": "entitlements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entitlement_grants_source_unique": { + "name": "entitlement_grants_source_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entitlement_grants_active_idx": { + "name": "entitlement_grants_active_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlement_grants_account_id_accounts_id_fk": { + "name": "entitlement_grants_account_id_accounts_id_fk", + "tableFrom": "entitlement_grants", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlement_grants_created_by_accounts_id_fk": { + "name": "entitlement_grants_created_by_accounts_id_fk", + "tableFrom": "entitlement_grants", + "tableTo": "accounts", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "entitlement_grants_schema_check": { + "name": "entitlement_grants_schema_check", + "value": "\"entitlement_grants\".\"schema_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authority": { + "name": "authority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'platform-admin'" + }, + "approved_by": { + "name": "approved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_staff_id": { + "name": "approved_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_by": { + "name": "released_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_by_staff_id": { + "name": "released_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "release_reason_code": { + "name": "release_reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "legal_holds_active_account_unique": { + "name": "legal_holds_active_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"legal_holds\".\"released_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "legal_holds_account_idx": { + "name": "legal_holds_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "approved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "legal_holds_account_id_accounts_id_fk": { + "name": "legal_holds_account_id_accounts_id_fk", + "tableFrom": "legal_holds", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_approved_by_accounts_id_fk": { + "name": "legal_holds_approved_by_accounts_id_fk", + "tableFrom": "legal_holds", + "tableTo": "accounts", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_approved_by_staff_id_staff_principals_id_fk": { + "name": "legal_holds_approved_by_staff_id_staff_principals_id_fk", + "tableFrom": "legal_holds", + "tableTo": "staff_principals", + "columnsFrom": [ + "approved_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_released_by_accounts_id_fk": { + "name": "legal_holds_released_by_accounts_id_fk", + "tableFrom": "legal_holds", + "tableTo": "accounts", + "columnsFrom": [ + "released_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_released_by_staff_id_staff_principals_id_fk": { + "name": "legal_holds_released_by_staff_id_staff_principals_id_fk", + "tableFrom": "legal_holds", + "tableTo": "staff_principals", + "columnsFrom": [ + "released_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_secret_payloads": { + "name": "mail_secret_payloads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "erased_at": { + "name": "erased_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": { + "mail_secret_payloads_expiry_idx": { + "name": "mail_secret_payloads_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mail_secret_payloads_version_check": { + "name": "mail_secret_payloads_version_check", + "value": "\"mail_secret_payloads\".\"payload_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.maintenance_state": { + "name": "maintenance_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "mode": { + "name": "mode", + "type": "maintenance_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "generation": { + "name": "generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entered_at": { + "name": "entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_versions": { + "name": "object_versions", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_object_id": { + "name": "parent_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name_ciphertext": { + "name": "name_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_blind_index": { + "name": "name_blind_index", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blob_refs": { + "name": "blob_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "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": { + "object_versions_created_idx": { + "name": "object_versions_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_versions_sequence_idx": { + "name": "object_versions_sequence_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_versions_workspace_id_workspaces_id_fk": { + "name": "object_versions_workspace_id_workspaces_id_fk", + "tableFrom": "object_versions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_versions_source_device_id_devices_id_fk": { + "name": "object_versions_source_device_id_devices_id_fk", + "tableFrom": "object_versions", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "object_versions_workspace_key_fk": { + "name": "object_versions_workspace_key_fk", + "tableFrom": "object_versions", + "tableTo": "workspace_keys", + "columnsFrom": [ + "workspace_id", + "key_version" + ], + "columnsTo": [ + "workspace_id", + "key_version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "object_versions_workspace_id_object_id_revision_pk": { + "name": "object_versions_workspace_id_object_id_revision_pk", + "columns": [ + "workspace_id", + "object_id", + "revision" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_object_id": { + "name": "parent_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name_ciphertext": { + "name": "name_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_blind_index": { + "name": "name_blind_index", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_revision": { + "name": "current_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blob_refs": { + "name": "blob_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "deleted_at": { + "name": "deleted_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": { + "objects_workspace_kind_idx": { + "name": "objects_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_updated_idx": { + "name": "objects_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_sibling_name_blind_idx": { + "name": "objects_sibling_name_blind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_blind_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"objects\".\"deleted_at\" is null and \"objects\".\"name_blind_index\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_workspace_key_fk": { + "name": "objects_workspace_key_fk", + "tableFrom": "objects", + "tableTo": "workspace_keys", + "columnsFrom": [ + "workspace_id", + "key_version" + ], + "columnsTo": [ + "workspace_id", + "key_version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "objects_workspace_id_object_id_pk": { + "name": "objects_workspace_id_object_id_pk", + "columns": [ + "workspace_id", + "object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operations": { + "name": "operations", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_revision": { + "name": "result_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "result_sequence": { + "name": "result_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operations_created_idx": { + "name": "operations_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operations_workspace_id_workspaces_id_fk": { + "name": "operations_workspace_id_workspaces_id_fk", + "tableFrom": "operations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operations_source_device_id_devices_id_fk": { + "name": "operations_source_device_id_devices_id_fk", + "tableFrom": "operations", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operations_workspace_id_operation_id_pk": { + "name": "operations_workspace_id_operation_id_pk", + "columns": [ + "workspace_id", + "operation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_messages": { + "name": "outbox_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_or_event": { + "name": "template_or_event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_ref": { + "name": "recipient_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "secret_payload_ref": { + "name": "secret_payload_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "outbox_message_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_messages_idempotency_unique": { + "name": "outbox_messages_idempotency_unique", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_messages_claim_idx": { + "name": "outbox_messages_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_acceptances": { + "name": "policy_acceptances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_snapshot": { + "name": "subject_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "policy_document_id": { + "name": "policy_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_prefix_hash": { + "name": "ip_prefix_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent_family": { + "name": "user_agent_family", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence_version": { + "name": "evidence_version", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "policy_acceptances_subject_idx": { + "name": "policy_acceptances_subject_idx", + "columns": [ + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "policy_acceptances_account_id_accounts_id_fk": { + "name": "policy_acceptances_account_id_accounts_id_fk", + "tableFrom": "policy_acceptances", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "policy_acceptances_policy_document_id_policy_documents_id_fk": { + "name": "policy_acceptances_policy_document_id_policy_documents_id_fk", + "tableFrom": "policy_acceptances", + "tableTo": "policy_documents", + "columnsFrom": [ + "policy_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_documents": { + "name": "policy_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_ref": { + "name": "content_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonicalization_version": { + "name": "canonicalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "requires_reacceptance": { + "name": "requires_reacceptance", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "policy_documents_version_locale_unique": { + "name": "policy_documents_version_locale_unique", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locale", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "issued_instance_auth_epoch": { + "name": "issued_instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_request_id": { + "name": "rotation_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rotation_response_ciphertext": { + "name": "rotation_response_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_response_expires_at": { + "name": "rotation_response_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_device_idx": { + "name": "refresh_tokens_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_account_id_accounts_id_fk": { + "name": "refresh_tokens_account_id_accounts_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refresh_tokens_device_id_devices_id_fk": { + "name": "refresh_tokens_device_id_devices_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registration_invitation_uses": { + "name": "registration_invitation_uses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invitation_id": { + "name": "invitation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "registration_invitation_uses_account_unique": { + "name": "registration_invitation_uses_account_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "registration_invitation_uses_invitation_idx": { + "name": "registration_invitation_uses_invitation_idx", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "registration_invitation_uses_invitation_id_registration_invitations_id_fk": { + "name": "registration_invitation_uses_invitation_id_registration_invitations_id_fk", + "tableFrom": "registration_invitation_uses", + "tableTo": "registration_invitations", + "columnsFrom": [ + "invitation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "registration_invitation_uses_account_id_accounts_id_fk": { + "name": "registration_invitation_uses_account_id_accounts_id_fk", + "tableFrom": "registration_invitation_uses", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registration_invitations": { + "name": "registration_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_key_id": { + "name": "token_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hint": { + "name": "token_hint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "invitation_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_snapshot": { + "name": "creator_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "bound_email_normalized": { + "name": "bound_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replaces_invitation_id": { + "name": "replaces_invitation_id", + "type": "uuid", + "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": { + "registration_invitations_token_hash_unique": { + "name": "registration_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "registration_invitations_active_idx": { + "name": "registration_invitations_active_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "registration_invitations_use_count_check": { + "name": "registration_invitations_use_count_check", + "value": "\"registration_invitations\".\"max_uses\" > 0 and \"registration_invitations\".\"use_count\" >= 0 and \"registration_invitations\".\"use_count\" <= \"registration_invitations\".\"max_uses\"" + } + }, + "isRLSEnabled": false + }, + "public.restore_credential_reviews": { + "name": "restore_credential_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "restore_marker_id": { + "name": "restore_marker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operator_kind": { + "name": "operator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local-operator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "restore_credential_reviews_marker_account_unique": { + "name": "restore_credential_reviews_marker_account_unique", + "columns": [ + { + "expression": "restore_marker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "restore_credential_reviews_restore_marker_id_restore_markers_id_fk": { + "name": "restore_credential_reviews_restore_marker_id_restore_markers_id_fk", + "tableFrom": "restore_credential_reviews", + "tableTo": "restore_markers", + "columnsFrom": [ + "restore_marker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "restore_credential_reviews_account_id_accounts_id_fk": { + "name": "restore_credential_reviews_account_id_accounts_id_fk", + "tableFrom": "restore_credential_reviews", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restore_drills": { + "name": "restore_drills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_run_id": { + "name": "backup_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "restore_drill_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "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": {}, + "foreignKeys": { + "restore_drills_backup_run_id_backup_runs_id_fk": { + "name": "restore_drills_backup_run_id_backup_runs_id_fk", + "tableFrom": "restore_drills", + "tableTo": "backup_runs", + "columnsFrom": [ + "backup_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restore_markers": { + "name": "restore_markers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "restore_marker_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "old_sync_epoch": { + "name": "old_sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "new_sync_epoch": { + "name": "new_sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "restored_through_sequence_by_workspace": { + "name": "restored_through_sequence_by_workspace", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sanitation_status": { + "name": "sanitation_status", + "type": "restore_sanitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "auth_epoch_after": { + "name": "auth_epoch_after", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bootstrap_token_cutoff": { + "name": "bootstrap_token_cutoff", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bootstrap_reissue_required": { + "name": "bootstrap_reissue_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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "restore_markers_new_epoch_unique": { + "name": "restore_markers_new_epoch_unique", + "columns": [ + { + "expression": "new_sync_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.risk_events": { + "name": "risk_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "identity_hash": { + "name": "identity_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_prefix_hash": { + "name": "ip_prefix_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent_family": { + "name": "user_agent_family", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_codes": { + "name": "reason_codes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "risk_events_account_created_idx": { + "name": "risk_events_account_created_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "risk_events_event_created_idx": { + "name": "risk_events_event_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "risk_events_account_id_accounts_id_fk": { + "name": "risk_events_account_id_accounts_id_fk", + "tableFrom": "risk_events", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.risk_provider_events": { + "name": "risk_provider_events", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_verified_at": { + "name": "signature_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload_redacted": { + "name": "payload_redacted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "risk_provider_event_status", + "typeSchema": "public", + "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": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "risk_provider_events_claim_idx": { + "name": "risk_provider_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "risk_provider_events_provider_provider_event_id_pk": { + "name": "risk_provider_events_provider_provider_event_id_pk", + "columns": [ + "provider", + "provider_event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.risk_restrictions": { + "name": "risk_restrictions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_type": { + "name": "subject_type", + "type": "risk_restriction_subject_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_ref": { + "name": "subject_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "risk_restriction_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "risk_restriction_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "risk_restriction_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_staff_id": { + "name": "created_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by_staff_id": { + "name": "revoked_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "risk_restrictions_active_unique": { + "name": "risk_restrictions_active_unique", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"risk_restrictions\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "risk_restrictions_subject_idx": { + "name": "risk_restrictions_subject_idx", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "risk_restrictions_created_by_staff_id_staff_principals_id_fk": { + "name": "risk_restrictions_created_by_staff_id_staff_principals_id_fk", + "tableFrom": "risk_restrictions", + "tableTo": "staff_principals", + "columnsFrom": [ + "created_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "risk_restrictions_revoked_by_staff_id_staff_principals_id_fk": { + "name": "risk_restrictions_revoked_by_staff_id_staff_principals_id_fk", + "tableFrom": "risk_restrictions", + "tableTo": "staff_principals", + "columnsFrom": [ + "revoked_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_metadata": { + "name": "server_metadata", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_principals": { + "name": "staff_principals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "external_issuer": { + "name": "external_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_subject": { + "name": "external_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_login": { + "name": "local_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_password_hash": { + "name": "local_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_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": { + "staff_principals_issuer_subject_unique": { + "name": "staff_principals_issuer_subject_unique", + "columns": [ + { + "expression": "external_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_principals_local_login_unique": { + "name": "staff_principals_local_login_unique", + "columns": [ + { + "expression": "local_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"staff_principals\".\"local_login\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_principals_active_idx": { + "name": "staff_principals_active_idx", + "columns": [ + { + "expression": "disabled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_role_assignments": { + "name": "staff_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_key": { + "name": "role_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by_staff_id": { + "name": "assigned_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_role_assignments_staff_role_idx": { + "name": "staff_role_assignments_staff_role_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_role_assignments_active_idx": { + "name": "staff_role_assignments_active_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_role_assignments_staff_id_staff_principals_id_fk": { + "name": "staff_role_assignments_staff_id_staff_principals_id_fk", + "tableFrom": "staff_role_assignments", + "tableTo": "staff_principals", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "staff_role_assignments_assigned_by_staff_id_staff_principals_id_fk": { + "name": "staff_role_assignments_assigned_by_staff_id_staff_principals_id_fk", + "tableFrom": "staff_role_assignments", + "tableTo": "staff_principals", + "columnsFrom": [ + "assigned_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_sessions": { + "name": "staff_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auth_strength": { + "name": "auth_strength", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "csrf_token_hash": { + "name": "csrf_token_hash", + "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()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_sessions_active_idx": { + "name": "staff_sessions_active_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_sessions_staff_id_staff_principals_id_fk": { + "name": "staff_sessions_staff_id_staff_principals_id_fk", + "tableFrom": "staff_sessions", + "tableTo": "staff_principals", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.step_up_grants": { + "name": "step_up_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "digest_key_id": { + "name": "digest_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "step_up_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action_audience": { + "name": "action_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_methods": { + "name": "auth_methods", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "step_up_grants_token_digest_unique": { + "name": "step_up_grants_token_digest_unique", + "columns": [ + { + "expression": "token_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "step_up_grants_actor_session_expiry_idx": { + "name": "step_up_grants_actor_session_expiry_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_cases": { + "name": "support_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_snapshot": { + "name": "account_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "category": { + "name": "category", + "type": "support_case_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "support_case_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "status": { + "name": "status", + "type": "support_case_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "support_case_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "assigned_staff_id": { + "name": "assigned_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assigned_staff_snapshot": { + "name": "assigned_staff_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "support_cases_account_updated_idx": { + "name": "support_cases_account_updated_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_cases_queue_idx": { + "name": "support_cases_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_cases_account_id_accounts_id_fk": { + "name": "support_cases_account_id_accounts_id_fk", + "tableFrom": "support_cases", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "support_cases_assigned_staff_id_staff_principals_id_fk": { + "name": "support_cases_assigned_staff_id_staff_principals_id_fk", + "tableFrom": "support_cases", + "tableTo": "staff_principals", + "columnsFrom": [ + "assigned_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_diagnostic_grants": { + "name": "support_diagnostic_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_version": { + "name": "scope_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_ciphertext": { + "name": "snapshot_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_key_id": { + "name": "snapshot_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_encryption_version": { + "name": "snapshot_encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "deleted_at": { + "name": "deleted_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": { + "support_diagnostic_grants_case_idx": { + "name": "support_diagnostic_grants_case_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_diagnostic_grants_account_idx": { + "name": "support_diagnostic_grants_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_diagnostic_grants_case_id_support_cases_id_fk": { + "name": "support_diagnostic_grants_case_id_support_cases_id_fk", + "tableFrom": "support_diagnostic_grants", + "tableTo": "support_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "support_diagnostic_grants_account_id_accounts_id_fk": { + "name": "support_diagnostic_grants_account_id_accounts_id_fk", + "tableFrom": "support_diagnostic_grants", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_diagnostic_grants_scope_check": { + "name": "support_diagnostic_grants_scope_check", + "value": "length(\"support_diagnostic_grants\".\"scope_version\") > 0" + }, + "support_diagnostic_grants_encryption_version_check": { + "name": "support_diagnostic_grants_encryption_version_check", + "value": "\"support_diagnostic_grants\".\"snapshot_encryption_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_type": { + "name": "author_type", + "type": "support_message_author_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_ref": { + "name": "author_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_snapshot": { + "name": "author_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "support_message_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "body_ciphertext": { + "name": "body_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_key_id": { + "name": "body_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_encryption_version": { + "name": "body_encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_format": { + "name": "body_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'plain'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_case_author_idempotency_unique": { + "name": "support_messages_case_author_idempotency_unique", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_messages_case_created_idx": { + "name": "support_messages_case_created_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_case_id_support_cases_id_fk": { + "name": "support_messages_case_id_support_cases_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_messages_encryption_version_check": { + "name": "support_messages_encryption_version_check", + "value": "\"support_messages\".\"body_encryption_version\" > 0" + }, + "support_messages_key_id_check": { + "name": "support_messages_key_id_check", + "value": "length(\"support_messages\".\"body_key_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.sync_bootstrap_objects": { + "name": "sync_bootstrap_objects", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_document_sequence": { + "name": "latest_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "checkpoint_document_sequence": { + "name": "checkpoint_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "checkpoint_key_version": { + "name": "checkpoint_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext": { + "name": "checkpoint_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext_hash": { + "name": "checkpoint_ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialized_revision": { + "name": "materialized_revision", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sync_bootstrap_objects_workspace_idx": { + "name": "sync_bootstrap_objects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_bootstrap_objects_session_id_sync_bootstrap_sessions_id_fk": { + "name": "sync_bootstrap_objects_session_id_sync_bootstrap_sessions_id_fk", + "tableFrom": "sync_bootstrap_objects", + "tableTo": "sync_bootstrap_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_bootstrap_objects_workspace_id_workspaces_id_fk": { + "name": "sync_bootstrap_objects_workspace_id_workspaces_id_fk", + "tableFrom": "sync_bootstrap_objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_bootstrap_objects_session_id_object_id_pk": { + "name": "sync_bootstrap_objects_session_id_object_id_pk", + "columns": [ + "session_id", + "object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_bootstrap_sessions": { + "name": "sync_bootstrap_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_sequence": { + "name": "snapshot_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_bootstrap_sessions_workspace_idx": { + "name": "sync_bootstrap_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_bootstrap_sessions_workspace_id_workspaces_id_fk": { + "name": "sync_bootstrap_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "sync_bootstrap_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_checkpoints": { + "name": "sync_checkpoints", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "covers_document_sequence": { + "name": "covers_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_sequence": { + "name": "event_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "materialized_revision": { + "name": "materialized_revision", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_checkpoints_document_idx": { + "name": "sync_checkpoints_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_checkpoints_workspace_id_workspaces_id_fk": { + "name": "sync_checkpoints_workspace_id_workspaces_id_fk", + "tableFrom": "sync_checkpoints", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_checkpoints_source_device_id_devices_id_fk": { + "name": "sync_checkpoints_source_device_id_devices_id_fk", + "tableFrom": "sync_checkpoints", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_checkpoints_workspace_id_checkpoint_id_pk": { + "name": "sync_checkpoints_workspace_id_checkpoint_id_pk", + "columns": [ + "workspace_id", + "checkpoint_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_commands": { + "name": "sync_commands", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "command_id": { + "name": "command_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_epoch": { + "name": "sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_commands_created_idx": { + "name": "sync_commands_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_commands_workspace_id_workspaces_id_fk": { + "name": "sync_commands_workspace_id_workspaces_id_fk", + "tableFrom": "sync_commands", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_commands_source_device_id_devices_id_fk": { + "name": "sync_commands_source_device_id_devices_id_fk", + "tableFrom": "sync_commands", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_commands_workspace_id_command_id_pk": { + "name": "sync_commands_workspace_id_command_id_pk", + "columns": [ + "workspace_id", + "command_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_conflicts": { + "name": "sync_conflicts", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conflict_id": { + "name": "conflict_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "conflict_type": { + "name": "conflict_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unresolved'" + }, + "expected_revision": { + "name": "expected_revision", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "expected_document_sequence": { + "name": "expected_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_sequence": { + "name": "created_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resolved_sequence": { + "name": "resolved_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolved_by_device_id": { + "name": "resolved_by_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "sync_conflicts_status_idx": { + "name": "sync_conflicts_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_conflicts_object_idx": { + "name": "sync_conflicts_object_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_workspace_id_workspaces_id_fk": { + "name": "sync_conflicts_workspace_id_workspaces_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_conflicts_resolved_by_device_id_devices_id_fk": { + "name": "sync_conflicts_resolved_by_device_id_devices_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "devices", + "columnsFrom": [ + "resolved_by_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_workspace_id_conflict_id_pk": { + "name": "sync_conflicts_workspace_id_conflict_id_pk", + "columns": [ + "workspace_id", + "conflict_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_device_cursors": { + "name": "sync_device_cursors", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "acknowledged_sequence": { + "name": "acknowledged_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_device_cursors_updated_idx": { + "name": "sync_device_cursors_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_device_cursors_workspace_id_workspaces_id_fk": { + "name": "sync_device_cursors_workspace_id_workspaces_id_fk", + "tableFrom": "sync_device_cursors", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_device_cursors_device_id_devices_id_fk": { + "name": "sync_device_cursors_device_id_devices_id_fk", + "tableFrom": "sync_device_cursors", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_device_cursors_workspace_id_device_id_pk": { + "name": "sync_device_cursors_workspace_id_device_id_pk", + "columns": [ + "workspace_id", + "device_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_documents": { + "name": "sync_documents", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "latest_document_sequence": { + "name": "latest_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "checkpoint_document_sequence": { + "name": "checkpoint_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "checkpoint_key_version": { + "name": "checkpoint_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext": { + "name": "checkpoint_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext_hash": { + "name": "checkpoint_ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialized_revision": { + "name": "materialized_revision", + "type": "bigint", + "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": { + "sync_documents_object_unique": { + "name": "sync_documents_object_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_documents_workspace_id_workspaces_id_fk": { + "name": "sync_documents_workspace_id_workspaces_id_fk", + "tableFrom": "sync_documents", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_documents_workspace_id_document_id_pk": { + "name": "sync_documents_workspace_id_document_id_pk", + "columns": [ + "workspace_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_events": { + "name": "sync_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "command_id": { + "name": "command_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_sequence": { + "name": "document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_events_workspace_sequence_unique": { + "name": "sync_events_workspace_sequence_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_workspace_event_unique": { + "name": "sync_events_workspace_event_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_document_idx": { + "name": "sync_events_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_created_idx": { + "name": "sync_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_events_workspace_id_workspaces_id_fk": { + "name": "sync_events_workspace_id_workspaces_id_fk", + "tableFrom": "sync_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_events_source_device_id_devices_id_fk": { + "name": "sync_events_source_device_id_devices_id_fk", + "tableFrom": "sync_events", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_resource_bindings": { + "name": "sync_resource_bindings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_object_id": { + "name": "owner_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_revision": { + "name": "owner_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resource_object_id": { + "name": "resource_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_revision": { + "name": "resource_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_resource_bindings_resource_idx": { + "name": "sync_resource_bindings_resource_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_resource_bindings_owner_idx": { + "name": "sync_resource_bindings_owner_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_resource_bindings_workspace_id_workspaces_id_fk": { + "name": "sync_resource_bindings_workspace_id_workspaces_id_fk", + "tableFrom": "sync_resource_bindings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_resource_bindings_owner_version_fk": { + "name": "sync_resource_bindings_owner_version_fk", + "tableFrom": "sync_resource_bindings", + "tableTo": "object_versions", + "columnsFrom": [ + "workspace_id", + "owner_object_id", + "owner_revision" + ], + "columnsTo": [ + "workspace_id", + "object_id", + "revision" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_resource_bindings_resource_version_fk": { + "name": "sync_resource_bindings_resource_version_fk", + "tableFrom": "sync_resource_bindings", + "tableTo": "object_versions", + "columnsFrom": [ + "workspace_id", + "resource_object_id", + "resource_revision" + ], + "columnsTo": [ + "workspace_id", + "object_id", + "revision" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_resource_bindings_workspace_id_owner_object_id_owner_revision_resource_object_id_pk": { + "name": "sync_resource_bindings_workspace_id_owner_object_id_owner_revision_resource_object_id_pk", + "columns": [ + "workspace_id", + "owner_object_id", + "owner_revision", + "resource_object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_updates": { + "name": "sync_updates", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_sequence": { + "name": "document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "update_id": { + "name": "update_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_sequence": { + "name": "event_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_updates_id_unique": { + "name": "sync_updates_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_updates_event_idx": { + "name": "sync_updates_event_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_updates_workspace_id_workspaces_id_fk": { + "name": "sync_updates_workspace_id_workspaces_id_fk", + "tableFrom": "sync_updates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_updates_source_device_id_devices_id_fk": { + "name": "sync_updates_source_device_id_devices_id_fk", + "tableFrom": "sync_updates", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_updates_workspace_id_document_id_document_sequence_pk": { + "name": "sync_updates_workspace_id_document_id_document_sequence_pk", + "columns": [ + "workspace_id", + "document_id", + "document_sequence" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_events": { + "name": "usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delta": { + "name": "delta", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resulting_value": { + "name": "resulting_value", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_period": { + "name": "billing_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "usage_events_workspace_idempotency_unique": { + "name": "usage_events_workspace_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_events\".\"workspace_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_events_account_idempotency_unique": { + "name": "usage_events_account_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_events\".\"workspace_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_events_account_occurred_idx": { + "name": "usage_events_account_occurred_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_events_account_id_accounts_id_fk": { + "name": "usage_events_account_id_accounts_id_fk", + "tableFrom": "usage_events", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_reservations": { + "name": "usage_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_upload_ref": { + "name": "provider_upload_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "usage_reservation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "usage_reservations_workspace_source_unique": { + "name": "usage_reservations_workspace_source_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_reservations\".\"workspace_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_reservations_account_source_unique": { + "name": "usage_reservations_account_source_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_reservations\".\"workspace_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_reservations_expiry_idx": { + "name": "usage_reservations_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_reservations_account_id_accounts_id_fk": { + "name": "usage_reservations_account_id_accounts_id_fk", + "tableFrom": "usage_reservations", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web_sessions": { + "name": "web_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "csrf_token_hash": { + "name": "csrf_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "issued_instance_auth_epoch": { + "name": "issued_instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web_sessions_token_hash_unique": { + "name": "web_sessions_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web_sessions_account_idx": { + "name": "web_sessions_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web_sessions_expiry_idx": { + "name": "web_sessions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "web_sessions_account_id_accounts_id_fk": { + "name": "web_sessions_account_id_accounts_id_fk", + "tableFrom": "web_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_key_envelopes": { + "name": "workspace_key_envelopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "envelope_type": { + "name": "envelope_type", + "type": "key_envelope_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wrapped_key": { + "name": "wrapped_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kdf_salt": { + "name": "kdf_salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kdf_params": { + "name": "kdf_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "key_envelope_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replacement_idempotency_key": { + "name": "replacement_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replacement_request_hash": { + "name": "replacement_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_key_envelopes_key_idx": { + "name": "workspace_key_envelopes_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_key_envelopes_active_recipient_unique": { + "name": "workspace_key_envelopes_active_recipient_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "envelope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"recipient_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_key_envelopes\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_key_envelopes_replacement_idempotency_unique": { + "name": "workspace_key_envelopes_replacement_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "replacement_idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_key_envelopes\".\"replacement_idempotency_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_key_envelopes_key_fk": { + "name": "workspace_key_envelopes_key_fk", + "tableFrom": "workspace_key_envelopes", + "tableTo": "workspace_keys", + "columnsFrom": [ + "workspace_id", + "key_version" + ], + "columnsTo": [ + "workspace_id", + "key_version" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_keys": { + "name": "workspace_keys", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_keys_workspace_id_workspaces_id_fk": { + "name": "workspace_keys_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_keys", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_keys_workspace_id_key_version_pk": { + "name": "workspace_keys_workspace_id_key_version_pk", + "columns": [ + "workspace_id", + "key_version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name_ciphertext": { + "name": "name_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creation_idempotency_key": { + "name": "creation_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creation_request_hash": { + "name": "creation_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latest_sequence": { + "name": "latest_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "deleted_at": { + "name": "deleted_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": { + "workspaces_account_idx": { + "name": "workspaces_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_active_default_unique": { + "name": "workspaces_active_default_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspaces\".\"is_default\" = true and \"workspaces\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_account_creation_idempotency_unique": { + "name": "workspaces_account_creation_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creation_idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspaces\".\"creation_idempotency_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_account_id_accounts_id_fk": { + "name": "workspaces_account_id_accounts_id_fk", + "tableFrom": "workspaces", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_action_token_purpose": { + "name": "account_action_token_purpose", + "schema": "public", + "values": [ + "verify_email", + "reset_password", + "change_email" + ] + }, + "public.account_identity_kind": { + "name": "account_identity_kind", + "schema": "public", + "values": [ + "username", + "email" + ] + }, + "public.account_identity_state": { + "name": "account_identity_state", + "schema": "public", + "values": [ + "pending_verification", + "active", + "legacy_migration" + ] + }, + "public.account_login_claim_kind": { + "name": "account_login_claim_kind", + "schema": "public", + "values": [ + "legacy_username", + "username", + "email" + ] + }, + "public.account_subscription_status": { + "name": "account_subscription_status", + "schema": "public", + "values": [ + "incomplete", + "trialing", + "active", + "past_due", + "grace", + "paused", + "ended", + "review" + ] + }, + "public.background_job_status": { + "name": "background_job_status", + "schema": "public", + "values": [ + "pending", + "running", + "succeeded", + "dead_letter", + "cancelled" + ] + }, + "public.backup_run_status": { + "name": "backup_run_status", + "schema": "public", + "values": [ + "queued", + "preparing", + "draining", + "dumping", + "copying", + "verifying", + "ready", + "failed", + "deleting" + ] + }, + "public.billing_checkout_status": { + "name": "billing_checkout_status", + "schema": "public", + "values": [ + "pending_provider", + "open", + "completed", + "linked", + "expired", + "failed", + "reconciling" + ] + }, + "public.billing_plan_interval": { + "name": "billing_plan_interval", + "schema": "public", + "values": [ + "month", + "year" + ] + }, + "public.billing_provider_environment": { + "name": "billing_provider_environment", + "schema": "public", + "values": [ + "test", + "live" + ] + }, + "public.billing_webhook_event_status": { + "name": "billing_webhook_event_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed", + "ignored", + "failed", + "dead_letter" + ] + }, + "public.blob_state": { + "name": "blob_state", + "schema": "public", + "values": [ + "uploading", + "ready", + "deleting" + ] + }, + "public.bootstrap_credential_source": { + "name": "bootstrap_credential_source", + "schema": "public", + "values": [ + "cli", + "legacy_environment" + ] + }, + "public.change_type": { + "name": "change_type", + "schema": "public", + "values": [ + "upsert", + "delete" + ] + }, + "public.data_request_status": { + "name": "data_request_status", + "schema": "public", + "values": [ + "submitted", + "identity_check", + "queued", + "processing", + "awaiting_user", + "completed", + "rejected", + "canceled", + "held", + "failed" + ] + }, + "public.data_request_type": { + "name": "data_request_type", + "schema": "public", + "values": [ + "access", + "export", + "correct", + "delete", + "restrict", + "object" + ] + }, + "public.deletion_case_status": { + "name": "deletion_case_status", + "schema": "public", + "values": [ + "requested", + "cooling_off", + "scheduled", + "held", + "purging", + "completed", + "canceled", + "failed" + ] + }, + "public.deletion_fence_state": { + "name": "deletion_fence_state", + "schema": "public", + "values": [ + "cooling_off", + "scheduled", + "purging", + "completed", + "canceled" + ] + }, + "public.deletion_step_state": { + "name": "deletion_step_state", + "schema": "public", + "values": [ + "pending", + "running", + "completed", + "failed", + "skipped" + ] + }, + "public.deployment_mode": { + "name": "deployment_mode", + "schema": "public", + "values": [ + "hosted", + "self-hosted" + ] + }, + "public.device_authorization_status": { + "name": "device_authorization_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.entitlement_grant_source": { + "name": "entitlement_grant_source", + "schema": "public", + "values": [ + "promotion", + "support", + "migration", + "staff" + ] + }, + "public.invitation_actor_type": { + "name": "invitation_actor_type", + "schema": "public", + "values": [ + "account", + "staff", + "system" + ] + }, + "public.key_envelope_status": { + "name": "key_envelope_status", + "schema": "public", + "values": [ + "active", + "revoked" + ] + }, + "public.key_envelope_type": { + "name": "key_envelope_type", + "schema": "public", + "values": [ + "passphrase", + "recovery", + "device", + "managed" + ] + }, + "public.maintenance_mode": { + "name": "maintenance_mode", + "schema": "public", + "values": [ + "normal", + "read_only", + "write_drain", + "offline" + ] + }, + "public.object_kind": { + "name": "object_kind", + "schema": "public", + "values": [ + "note", + "folder", + "asset", + "canvas", + "record", + "tag", + "mark", + "conversation", + "memory", + "setting", + "yjs-checkpoint", + "yjs-update" + ] + }, + "public.outbox_message_status": { + "name": "outbox_message_status", + "schema": "public", + "values": [ + "pending", + "sending", + "sent", + "dead_letter", + "delivery_unknown" + ] + }, + "public.registration_policy": { + "name": "registration_policy", + "schema": "public", + "values": [ + "bootstrap", + "disabled", + "invitation", + "public" + ] + }, + "public.restore_drill_mode": { + "name": "restore_drill_mode", + "schema": "public", + "values": [ + "verify-only", + "isolated-restore", + "full-drill" + ] + }, + "public.restore_marker_mode": { + "name": "restore_marker_mode", + "schema": "public", + "values": [ + "preserve", + "clone" + ] + }, + "public.restore_sanitation_status": { + "name": "restore_sanitation_status", + "schema": "public", + "values": [ + "pending", + "running", + "complete", + "failed" + ] + }, + "public.risk_provider_event_status": { + "name": "risk_provider_event_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed", + "ignored", + "failed", + "dead_letter" + ] + }, + "public.risk_restriction_action": { + "name": "risk_restriction_action", + "schema": "public", + "values": [ + "challenge", + "lock", + "read_only", + "deny", + "review" + ] + }, + "public.risk_restriction_scope": { + "name": "risk_restriction_scope", + "schema": "public", + "values": [ + "registration", + "authentication", + "recovery", + "device", + "sync_write", + "blob", + "billing", + "all" + ] + }, + "public.risk_restriction_source": { + "name": "risk_restriction_source", + "schema": "public", + "values": [ + "automatic", + "staff", + "provider" + ] + }, + "public.risk_restriction_subject_type": { + "name": "risk_restriction_subject_type", + "schema": "public", + "values": [ + "account", + "identity", + "device", + "ip_prefix" + ] + }, + "public.self_hosted_lifecycle": { + "name": "self_hosted_lifecycle", + "schema": "public", + "values": [ + "uninitialized", + "ready" + ] + }, + "public.step_up_actor_type": { + "name": "step_up_actor_type", + "schema": "public", + "values": [ + "account", + "staff" + ] + }, + "public.support_case_category": { + "name": "support_case_category", + "schema": "public", + "values": [ + "account", + "sync", + "device", + "encryption", + "billing", + "privacy", + "abuse", + "other" + ] + }, + "public.support_case_severity": { + "name": "support_case_severity", + "schema": "public", + "values": [ + "normal", + "high", + "urgent" + ] + }, + "public.support_case_source": { + "name": "support_case_source", + "schema": "public", + "values": [ + "web", + "client", + "email", + "staff", + "external" + ] + }, + "public.support_case_status": { + "name": "support_case_status", + "schema": "public", + "values": [ + "open", + "waiting_for_support", + "waiting_for_user", + "resolved", + "closed", + "spam" + ] + }, + "public.support_message_author_type": { + "name": "support_message_author_type", + "schema": "public", + "values": [ + "account", + "staff", + "system" + ] + }, + "public.support_message_visibility": { + "name": "support_message_visibility", + "schema": "public", + "values": [ + "customer", + "internal" + ] + }, + "public.usage_reservation_status": { + "name": "usage_reservation_status", + "schema": "public", + "values": [ + "reserved", + "external_started", + "committed", + "released", + "expired", + "reconciling" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0003_snapshot.json b/apps/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..2bd2e3f --- /dev/null +++ b/apps/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,11522 @@ +{ + "id": "7fc8704e-8d84-4c58-af1d-810964b970b4", + "prevId": "a76dbd30-2474-48bd-adb7-f17389833a92", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "identity_id": { + "name": "identity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "account_action_token_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_key_id": { + "name": "token_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_normalized": { + "name": "target_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "requested_ip_hash": { + "name": "requested_ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_account_idx": { + "name": "account_action_tokens_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_account_id_accounts_id_fk": { + "name": "account_action_tokens_account_id_accounts_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_action_tokens_identity_id_account_identities_id_fk": { + "name": "account_action_tokens_identity_id_account_identities_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "account_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_deletion_cases": { + "name": "account_deletion_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "deletion_case_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "cancel_until": { + "name": "cancel_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "purge_after": { + "name": "purge_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_credential_hash": { + "name": "cancel_credential_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purge_manifest_ref": { + "name": "purge_manifest_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purge_manifest_hash": { + "name": "purge_manifest_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purge_manifest": { + "name": "purge_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_deletion_cases_active_account_unique": { + "name": "account_deletion_cases_active_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_deletion_cases\".\"account_id\" is not null and \"account_deletion_cases\".\"status\" in ('requested', 'cooling_off', 'scheduled', 'held', 'purging')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_deletion_cases_active_subject_unique": { + "name": "account_deletion_cases_active_subject_unique", + "columns": [ + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_deletion_cases\".\"status\" in ('requested', 'cooling_off', 'scheduled', 'held', 'purging')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_deletion_fences": { + "name": "account_deletion_fences", + "schema": "", + "columns": { + "account_uuid": { + "name": "account_uuid", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "state": { + "name": "state", + "type": "deletion_fence_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "hold_revision": { + "name": "hold_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "blocks_domain_writes": { + "name": "blocks_domain_writes", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_identities": { + "name": "account_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "account_identity_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_identifier": { + "name": "normalized_identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_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": { + "account_identities_active_unique": { + "name": "account_identities_active_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_identities\".\"disabled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_identities_account_idx": { + "name": "account_identities_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_identities_account_id_accounts_id_fk": { + "name": "account_identities_account_id_accounts_id_fk", + "tableFrom": "account_identities", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_login_claim_conflicts": { + "name": "account_login_claim_conflicts", + "schema": "", + "columns": { + "normalized_login_key": { + "name": "normalized_login_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "candidate_account_id": { + "name": "candidate_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "candidate_identity_id": { + "name": "candidate_identity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "candidate_kind": { + "name": "candidate_kind", + "type": "account_login_claim_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'quarantined'" + }, + "resolution_ref": { + "name": "resolution_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "account_login_claim_conflicts_status_idx": { + "name": "account_login_claim_conflicts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_login_claim_conflicts_candidate_account_id_accounts_id_fk": { + "name": "account_login_claim_conflicts_candidate_account_id_accounts_id_fk", + "tableFrom": "account_login_claim_conflicts", + "tableTo": "accounts", + "columnsFrom": [ + "candidate_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "account_login_claim_conflicts_candidate_identity_id_account_identities_id_fk": { + "name": "account_login_claim_conflicts_candidate_identity_id_account_identities_id_fk", + "tableFrom": "account_login_claim_conflicts", + "tableTo": "account_identities", + "columnsFrom": [ + "candidate_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_login_claim_conflicts_normalized_login_key_candidate_account_id_candidate_kind_pk": { + "name": "account_login_claim_conflicts_normalized_login_key_candidate_account_id_candidate_kind_pk", + "columns": [ + "normalized_login_key", + "candidate_account_id", + "candidate_kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_login_claims": { + "name": "account_login_claims", + "schema": "", + "columns": { + "normalized_login_key": { + "name": "normalized_login_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "identity_id": { + "name": "identity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "account_login_claim_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reusable_after": { + "name": "reusable_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_login_claims_account_id_accounts_id_fk": { + "name": "account_login_claims_account_id_accounts_id_fk", + "tableFrom": "account_login_claims", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "account_login_claims_identity_id_account_identities_id_fk": { + "name": "account_login_claims_identity_id_account_identities_id_fk", + "tableFrom": "account_login_claims", + "tableTo": "account_identities", + "columnsFrom": [ + "identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_service_audit_events": { + "name": "account_service_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_service_audit_events_actor_idx": { + "name": "account_service_audit_events_actor_idx", + "columns": [ + { + "expression": "actor_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_service_audit_events_target_idx": { + "name": "account_service_audit_events_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "account_service_audit_events_actor_type_check": { + "name": "account_service_audit_events_actor_type_check", + "value": "\"account_service_audit_events\".\"actor_type\" in ('account', 'staff', 'system', 'webhook')" + }, + "account_service_audit_events_action_check": { + "name": "account_service_audit_events_action_check", + "value": "length(\"account_service_audit_events\".\"action\") > 0" + }, + "account_service_audit_events_target_type_check": { + "name": "account_service_audit_events_target_type_check", + "value": "length(\"account_service_audit_events\".\"target_type\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.account_subscriptions": { + "name": "account_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_version_id": { + "name": "plan_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "account_subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_revision": { + "name": "provider_revision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "grace_ends_at": { + "name": "grace_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "account_subscriptions_provider_subscription_unique": { + "name": "account_subscriptions_provider_subscription_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_subscriptions_current_account_unique": { + "name": "account_subscriptions_current_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"account_subscriptions\".\"account_id\" is not null and \"account_subscriptions\".\"is_current\"", + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_subscriptions_account_status_idx": { + "name": "account_subscriptions_account_status_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_subscriptions_account_id_accounts_id_fk": { + "name": "account_subscriptions_account_id_accounts_id_fk", + "tableFrom": "account_subscriptions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "account_subscriptions_plan_version_id_billing_plan_versions_id_fk": { + "name": "account_subscriptions_plan_version_id_billing_plan_versions_id_fk", + "tableFrom": "account_subscriptions", + "tableTo": "billing_plan_versions", + "columnsFrom": [ + "plan_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_usage": { + "name": "account_usage", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "active_object_bytes": { + "name": "active_object_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_crdt_bytes": { + "name": "active_crdt_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_blob_bytes": { + "name": "active_blob_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "reserved_blob_bytes": { + "name": "reserved_blob_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "retained_bytes": { + "name": "retained_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_objects": { + "name": "active_objects", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_devices": { + "name": "active_devices", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "active_workspaces": { + "name": "active_workspaces", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_usage_account_id_accounts_id_fk": { + "name": "account_usage_account_id_accounts_id_fk", + "tableFrom": "account_usage", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled_at": { + "name": "totp_enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "identity_state": { + "name": "identity_state", + "type": "account_identity_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'legacy_migration'" + }, + "credential_epoch": { + "name": "credential_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "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": { + "accounts_login_unique": { + "name": "accounts_login_unique", + "columns": [ + { + "expression": "lower(\"login\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_logs": { + "name": "admin_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_logs_actor_idx": { + "name": "admin_audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_logs_created_idx": { + "name": "admin_audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_logs_actor_account_id_accounts_id_fk": { + "name": "admin_audit_logs_actor_account_id_accounts_id_fk", + "tableFrom": "admin_audit_logs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_backups": { + "name": "admin_backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "admin_backups_filename_unique": { + "name": "admin_backups_filename_unique", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_backups_job_id_admin_jobs_id_fk": { + "name": "admin_backups_job_id_admin_jobs_id_fk", + "tableFrom": "admin_backups", + "tableTo": "admin_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_jobs": { + "name": "admin_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_jobs_created_idx": { + "name": "admin_jobs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_jobs_status_idx": { + "name": "admin_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_jobs_actor_account_id_accounts_id_fk": { + "name": "admin_jobs_actor_account_id_accounts_id_fk", + "tableFrom": "admin_jobs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_test_objects": { + "name": "admin_test_objects", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_by_account_id": { + "name": "created_by_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_test_objects_created_idx": { + "name": "admin_test_objects_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_test_objects_workspace_id_workspaces_id_fk": { + "name": "admin_test_objects_workspace_id_workspaces_id_fk", + "tableFrom": "admin_test_objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "admin_test_objects_created_by_account_id_accounts_id_fk": { + "name": "admin_test_objects_created_by_account_id_accounts_id_fk", + "tableFrom": "admin_test_objects", + "tableTo": "accounts", + "columnsFrom": [ + "created_by_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "admin_test_objects_workspace_id_object_id_pk": { + "name": "admin_test_objects_workspace_id_object_id_pk", + "columns": [ + "workspace_id", + "object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_jobs": { + "name": "background_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_generation": { + "name": "queue_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "min_handler_version": { + "name": "min_handler_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "actor_account_id": { + "name": "actor_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_account_id": { + "name": "target_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "background_jobs_idempotency_unique": { + "name": "background_jobs_idempotency_unique", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_jobs_claim_idx": { + "name": "background_jobs_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_jobs_actor_account_id_accounts_id_fk": { + "name": "background_jobs_actor_account_id_accounts_id_fk", + "tableFrom": "background_jobs", + "tableTo": "accounts", + "columnsFrom": [ + "actor_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "background_jobs_target_account_id_accounts_id_fk": { + "name": "background_jobs_target_account_id_accounts_id_fk", + "tableFrom": "background_jobs", + "tableTo": "accounts", + "columnsFrom": [ + "target_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "background_jobs_payload_version_check": { + "name": "background_jobs_payload_version_check", + "value": "\"background_jobs\".\"payload_version\" > 0" + }, + "background_jobs_queue_generation_check": { + "name": "background_jobs_queue_generation_check", + "value": "\"background_jobs\".\"queue_generation\" > 0" + }, + "background_jobs_min_handler_version_check": { + "name": "background_jobs_min_handler_version_check", + "value": "\"background_jobs\".\"min_handler_version\" > 0" + }, + "background_jobs_attempt_check": { + "name": "background_jobs_attempt_check", + "value": "\"background_jobs\".\"attempt\" >= 0" + }, + "background_jobs_max_attempts_check": { + "name": "background_jobs_max_attempts_check", + "value": "\"background_jobs\".\"max_attempts\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.backup_artifacts": { + "name": "backup_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_run_id": { + "name": "backup_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "backup_artifacts_run_path_unique": { + "name": "backup_artifacts_run_path_unique", + "columns": [ + { + "expression": "backup_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relative_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backup_artifacts_run_idx": { + "name": "backup_artifacts_run_idx", + "columns": [ + { + "expression": "backup_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "backup_artifacts_backup_run_id_backup_runs_id_fk": { + "name": "backup_artifacts_backup_run_id_backup_runs_id_fk", + "tableFrom": "backup_artifacts", + "tableTo": "backup_runs", + "columnsFrom": [ + "backup_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "backup_artifacts_size_check": { + "name": "backup_artifacts_size_check", + "value": "\"backup_artifacts\".\"size\" >= 0" + }, + "backup_artifacts_path_check": { + "name": "backup_artifacts_path_check", + "value": "length(\"backup_artifacts\".\"relative_path\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.backup_policies": { + "name": "backup_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_ref": { + "name": "target_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retention": { + "name": "retention", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encryption_key_id": { + "name": "encryption_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_runs": { + "name": "backup_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "generation": { + "name": "generation", + "type": "bigserial", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "backup_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "snapshot_at": { + "name": "snapshot_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "manifest_ref": { + "name": "manifest_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_bytes": { + "name": "database_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "blob_count": { + "name": "blob_count", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "blob_bytes": { + "name": "blob_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkpoint": { + "name": "checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "backup_runs_generation_unique": { + "name": "backup_runs_generation_unique", + "columns": [ + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "backup_runs_status_idx": { + "name": "backup_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "backup_runs_policy_id_backup_policies_id_fk": { + "name": "backup_runs_policy_id_backup_policies_id_fk", + "tableFrom": "backup_runs", + "tableTo": "backup_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_account_states": { + "name": "billing_account_states", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "current_subscription_id": { + "name": "current_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "purchase_intent_id": { + "name": "purchase_intent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_account_states_account_id_accounts_id_fk": { + "name": "billing_account_states_account_id_accounts_id_fk", + "tableFrom": "billing_account_states", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_account_states_current_subscription_id_account_subscriptions_id_fk": { + "name": "billing_account_states_current_subscription_id_account_subscriptions_id_fk", + "tableFrom": "billing_account_states", + "tableTo": "account_subscriptions", + "columnsFrom": [ + "current_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "billing_account_states_purchase_intent_id_billing_checkout_sessions_id_fk": { + "name": "billing_account_states_purchase_intent_id_billing_checkout_sessions_id_fk", + "tableFrom": "billing_account_states", + "tableTo": "billing_checkout_sessions", + "columnsFrom": [ + "purchase_intent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_account_states_current_or_intent_check": { + "name": "billing_account_states_current_or_intent_check", + "value": "not (\"billing_account_states\".\"current_subscription_id\" is not null and \"billing_account_states\".\"purchase_intent_id\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.billing_checkout_sessions": { + "name": "billing_checkout_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_checkout_id": { + "name": "provider_checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "price_mapping_id": { + "name": "price_mapping_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_version_id": { + "name": "plan_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "billing_checkout_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_provider'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "billing_checkout_sessions_account_idempotency_unique": { + "name": "billing_checkout_sessions_account_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_sessions_provider_checkout_unique": { + "name": "billing_checkout_sessions_provider_checkout_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_checkout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_sessions\".\"provider_checkout_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_sessions_active_account_unique": { + "name": "billing_checkout_sessions_active_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_sessions\".\"status\" in ('pending_provider','open','completed','reconciling')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_checkout_sessions_account_id_accounts_id_fk": { + "name": "billing_checkout_sessions_account_id_accounts_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_checkout_sessions_subscription_id_account_subscriptions_id_fk": { + "name": "billing_checkout_sessions_subscription_id_account_subscriptions_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "account_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "billing_checkout_sessions_price_mapping_id_billing_price_mappings_id_fk": { + "name": "billing_checkout_sessions_price_mapping_id_billing_price_mappings_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "billing_price_mappings", + "columnsFrom": [ + "price_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_sessions_plan_version_id_billing_plan_versions_id_fk": { + "name": "billing_checkout_sessions_plan_version_id_billing_plan_versions_id_fk", + "tableFrom": "billing_checkout_sessions", + "tableTo": "billing_plan_versions", + "columnsFrom": [ + "plan_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_customers": { + "name": "billing_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "billing_customers_provider_customer_unique": { + "name": "billing_customers_provider_customer_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_customers_account_unique": { + "name": "billing_customers_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_customers\".\"account_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_customers_account_id_accounts_id_fk": { + "name": "billing_customers_account_id_accounts_id_fk", + "tableFrom": "billing_customers", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_plan_versions": { + "name": "billing_plan_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_key": { + "name": "plan_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "interval": { + "name": "interval", + "type": "billing_plan_interval", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entitlement_schema_version": { + "name": "entitlement_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlements": { + "name": "entitlements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "active_from": { + "name": "active_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "retired_at": { + "name": "retired_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": { + "billing_plan_versions_key_version_unique": { + "name": "billing_plan_versions_key_version_unique", + "columns": [ + { + "expression": "plan_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_plan_versions_amount_check": { + "name": "billing_plan_versions_amount_check", + "value": "\"billing_plan_versions\".\"amount_minor\" >= 0" + }, + "billing_plan_versions_version_check": { + "name": "billing_plan_versions_version_check", + "value": "\"billing_plan_versions\".\"version\" > 0" + }, + "billing_plan_versions_schema_check": { + "name": "billing_plan_versions_schema_check", + "value": "\"billing_plan_versions\".\"entitlement_schema_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_price_mappings": { + "name": "billing_price_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_version_id": { + "name": "plan_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_environment": { + "name": "provider_environment", + "type": "billing_provider_environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_price_id": { + "name": "provider_price_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval": { + "name": "interval", + "type": "billing_plan_interval", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_price_mappings_provider_price_unique": { + "name": "billing_price_mappings_provider_price_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_price_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_price_mappings_plan_provider_unique": { + "name": "billing_price_mappings_plan_provider_unique", + "columns": [ + { + "expression": "plan_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "interval", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_price_mappings_plan_version_id_billing_plan_versions_id_fk": { + "name": "billing_price_mappings_plan_version_id_billing_plan_versions_id_fk", + "tableFrom": "billing_price_mappings", + "tableTo": "billing_plan_versions", + "columnsFrom": [ + "plan_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_verified_at": { + "name": "signature_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "payload_redacted": { + "name": "payload_redacted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "billing_webhook_event_status", + "typeSchema": "public", + "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": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_webhook_events_claim_idx": { + "name": "billing_webhook_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_webhook_events_provider_provider_event_id_pk": { + "name": "billing_webhook_events_provider_provider_event_id_pk", + "columns": [ + "provider", + "provider_event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob_upload_parts": { + "name": "blob_upload_parts", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "part_number": { + "name": "part_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "blob_upload_parts_upload_id_blob_uploads_id_fk": { + "name": "blob_upload_parts_upload_id_blob_uploads_id_fk", + "tableFrom": "blob_upload_parts", + "tableTo": "blob_uploads", + "columnsFrom": [ + "upload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blob_upload_parts_upload_id_part_number_pk": { + "name": "blob_upload_parts_upload_id_part_number_pk", + "columns": [ + "upload_id", + "part_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob_uploads": { + "name": "blob_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_id": { + "name": "blob_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_reservation_id": { + "name": "usage_reservation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sync_epoch": { + "name": "sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expected_size": { + "name": "expected_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "received_size": { + "name": "received_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "completing_at": { + "name": "completing_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_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": { + "blob_uploads_workspace_blob_unique": { + "name": "blob_uploads_workspace_blob_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blob_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blob_uploads_workspace_blob_idx": { + "name": "blob_uploads_workspace_blob_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blob_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blob_uploads_expiry_idx": { + "name": "blob_uploads_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blob_uploads_usage_reservation_unique": { + "name": "blob_uploads_usage_reservation_unique", + "columns": [ + { + "expression": "usage_reservation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"blob_uploads\".\"usage_reservation_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blob_uploads_workspace_id_workspaces_id_fk": { + "name": "blob_uploads_workspace_id_workspaces_id_fk", + "tableFrom": "blob_uploads", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blob_uploads_usage_reservation_id_usage_reservations_id_fk": { + "name": "blob_uploads_usage_reservation_id_usage_reservations_id_fk", + "tableFrom": "blob_uploads", + "tableTo": "usage_reservations", + "columnsFrom": [ + "usage_reservation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blobs": { + "name": "blobs", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blob_id": { + "name": "blob_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "blob_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_referenced_at": { + "name": "last_referenced_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": { + "blobs_storage_key_unique": { + "name": "blobs_storage_key_unique", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "blobs_gc_idx": { + "name": "blobs_gc_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_referenced_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blobs_workspace_id_workspaces_id_fk": { + "name": "blobs_workspace_id_workspaces_id_fk", + "tableFrom": "blobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blobs_workspace_id_blob_id_pk": { + "name": "blobs_workspace_id_blob_id_pk", + "columns": [ + "workspace_id", + "blob_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bootstrap_credentials": { + "name": "bootstrap_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "bootstrap_credential_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_key_id": { + "name": "token_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hint": { + "name": "token_hint", + "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 + }, + "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": { + "bootstrap_credentials_token_hash_unique": { + "name": "bootstrap_credentials_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bootstrap_credentials_active_idx": { + "name": "bootstrap_credentials_active_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bootstrap_sessions": { + "name": "bootstrap_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_sequence": { + "name": "snapshot_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bootstrap_sessions_expiry_idx": { + "name": "bootstrap_sessions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bootstrap_sessions_device_idx": { + "name": "bootstrap_sessions_device_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bootstrap_sessions_workspace_id_workspaces_id_fk": { + "name": "bootstrap_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "bootstrap_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bootstrap_sessions_device_id_devices_id_fk": { + "name": "bootstrap_sessions_device_id_devices_id_fk", + "tableFrom": "bootstrap_sessions", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_consumptions": { + "name": "challenge_consumptions", + "schema": "", + "columns": { + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "digest_key_id": { + "name": "digest_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expected_hostname": { + "name": "expected_hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_claims_hash": { + "name": "verified_claims_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changes": { + "name": "changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "change_type": { + "name": "change_type", + "type": "change_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "changes_workspace_sequence_unique": { + "name": "changes_workspace_sequence_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changes_workspace_object_idx": { + "name": "changes_workspace_object_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changes_created_idx": { + "name": "changes_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "changes_workspace_id_workspaces_id_fk": { + "name": "changes_workspace_id_workspaces_id_fk", + "tableFrom": "changes", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "changes_source_device_id_devices_id_fk": { + "name": "changes_source_device_id_devices_id_fk", + "tableFrom": "changes", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_requests": { + "name": "data_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_idempotency_key": { + "name": "client_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "data_request_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "request_channel": { + "name": "request_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_at": { + "name": "due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_ref": { + "name": "result_ref", + "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": { + "data_requests_subject_idempotency_unique": { + "name": "data_requests_subject_idempotency_unique", + "columns": [ + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_requests_status_idx": { + "name": "data_requests_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_requests_account_id_accounts_id_fk": { + "name": "data_requests_account_id_accounts_id_fk", + "tableFrom": "data_requests", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_case_steps": { + "name": "deletion_case_steps", + "schema": "", + "columns": { + "deletion_case_id": { + "name": "deletion_case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "handler": { + "name": "handler", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "deletion_step_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "deletion_case_steps_deletion_case_id_account_deletion_cases_id_fk": { + "name": "deletion_case_steps_deletion_case_id_account_deletion_cases_id_fk", + "tableFrom": "deletion_case_steps", + "tableTo": "account_deletion_cases", + "columnsFrom": [ + "deletion_case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deletion_case_steps_deletion_case_id_handler_pk": { + "name": "deletion_case_steps_deletion_case_id_handler_pk", + "columns": [ + "deletion_case_id", + "handler" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_ledger": { + "name": "deletion_ledger", + "schema": "", + "columns": { + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "hash_key_id": { + "name": "hash_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deletion_case_id": { + "name": "deletion_case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "minimum_backup_generation": { + "name": "minimum_backup_generation", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "minimum_database_lsn": { + "name": "minimum_database_lsn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "receipt_hash": { + "name": "receipt_hash", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deletion_ledger_outbox": { + "name": "deletion_ledger_outbox", + "schema": "", + "columns": { + "deletion_case_id": { + "name": "deletion_case_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "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()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deletion_ledger_outbox_claim_idx": { + "name": "deletion_ledger_outbox_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deletion_ledger_outbox_deletion_case_id_account_deletion_cases_id_fk": { + "name": "deletion_ledger_outbox_deletion_case_id_account_deletion_cases_id_fk", + "tableFrom": "deletion_ledger_outbox", + "tableTo": "account_deletion_cases", + "columnsFrom": [ + "deletion_case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deletion_ledger_outbox_idempotency_key_unique": { + "name": "deletion_ledger_outbox_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "deployment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "registration_policy": { + "name": "registration_policy", + "type": "registration_policy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "self_hosted_lifecycle": { + "name": "self_hosted_lifecycle", + "type": "self_hosted_lifecycle", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "admin_repair_required": { + "name": "admin_repair_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "instance_auth_epoch": { + "name": "instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "token_not_before": { + "name": "token_not_before", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "auth_epoch_enforced": { + "name": "auth_epoch_enforced", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "runtime_configuration": { + "name": "runtime_configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "configuration_revision": { + "name": "configuration_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "1" + }, + "initialized_at": { + "name": "initialized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "initialized_by_account_id": { + "name": "initialized_by_account_id", + "type": "uuid", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_authorizations": { + "name": "device_authorizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_public_key": { + "name": "encryption_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "device_authorization_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "approved_credential_epoch": { + "name": "approved_credential_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "approved_instance_auth_epoch": { + "name": "approved_instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "approved_issued_at": { + "name": "approved_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_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": { + "device_authorizations_device_code_unique": { + "name": "device_authorizations_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_authorizations_user_code_unique": { + "name": "device_authorizations_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_authorizations_expiry_idx": { + "name": "device_authorizations_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_authorizations_account_idx": { + "name": "device_authorizations_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_authorizations_account_id_accounts_id_fk": { + "name": "device_authorizations_account_id_accounts_id_fk", + "tableFrom": "device_authorizations", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_cursors": { + "name": "device_cursors", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "acknowledged_sequence": { + "name": "acknowledged_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_cursors_workspace_id_workspaces_id_fk": { + "name": "device_cursors_workspace_id_workspaces_id_fk", + "tableFrom": "device_cursors", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_cursors_device_id_devices_id_fk": { + "name": "device_cursors_device_id_devices_id_fk", + "tableFrom": "device_cursors", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "device_cursors_workspace_id_device_id_pk": { + "name": "device_cursors_workspace_id_device_id_pk", + "columns": [ + "workspace_id", + "device_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_pairings": { + "name": "device_pairings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credential_epoch": { + "name": "credential_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "instance_auth_epoch": { + "name": "instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_pairings_token_hash_unique": { + "name": "device_pairings_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_pairings_account_idx": { + "name": "device_pairings_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_pairings_expiry_idx": { + "name": "device_pairings_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_pairings_account_id_accounts_id_fk": { + "name": "device_pairings_account_id_accounts_id_fk", + "tableFrom": "device_pairings", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encryption_public_key": { + "name": "encryption_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_account_idx": { + "name": "devices_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_account_id_accounts_id_fk": { + "name": "devices_account_id_accounts_id_fk", + "tableFrom": "devices", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entitlement_grants": { + "name": "entitlement_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "entitlement_grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entitlements": { + "name": "entitlements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entitlement_grants_source_unique": { + "name": "entitlement_grants_source_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "entitlement_grants_active_idx": { + "name": "entitlement_grants_active_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entitlement_grants_account_id_accounts_id_fk": { + "name": "entitlement_grants_account_id_accounts_id_fk", + "tableFrom": "entitlement_grants", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entitlement_grants_created_by_accounts_id_fk": { + "name": "entitlement_grants_created_by_accounts_id_fk", + "tableFrom": "entitlement_grants", + "tableTo": "accounts", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "entitlement_grants_schema_check": { + "name": "entitlement_grants_schema_check", + "value": "\"entitlement_grants\".\"schema_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.legal_holds": { + "name": "legal_holds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authority": { + "name": "authority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'platform-admin'" + }, + "approved_by": { + "name": "approved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_staff_id": { + "name": "approved_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "released_by": { + "name": "released_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_by_staff_id": { + "name": "released_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "release_reason_code": { + "name": "release_reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "legal_holds_active_account_unique": { + "name": "legal_holds_active_account_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"legal_holds\".\"released_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "legal_holds_account_idx": { + "name": "legal_holds_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "approved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "legal_holds_account_id_accounts_id_fk": { + "name": "legal_holds_account_id_accounts_id_fk", + "tableFrom": "legal_holds", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_approved_by_accounts_id_fk": { + "name": "legal_holds_approved_by_accounts_id_fk", + "tableFrom": "legal_holds", + "tableTo": "accounts", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_approved_by_staff_id_staff_principals_id_fk": { + "name": "legal_holds_approved_by_staff_id_staff_principals_id_fk", + "tableFrom": "legal_holds", + "tableTo": "staff_principals", + "columnsFrom": [ + "approved_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_released_by_accounts_id_fk": { + "name": "legal_holds_released_by_accounts_id_fk", + "tableFrom": "legal_holds", + "tableTo": "accounts", + "columnsFrom": [ + "released_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "legal_holds_released_by_staff_id_staff_principals_id_fk": { + "name": "legal_holds_released_by_staff_id_staff_principals_id_fk", + "tableFrom": "legal_holds", + "tableTo": "staff_principals", + "columnsFrom": [ + "released_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_secret_payloads": { + "name": "mail_secret_payloads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "erased_at": { + "name": "erased_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": { + "mail_secret_payloads_expiry_idx": { + "name": "mail_secret_payloads_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mail_secret_payloads_version_check": { + "name": "mail_secret_payloads_version_check", + "value": "\"mail_secret_payloads\".\"payload_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.maintenance_state": { + "name": "maintenance_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "mode": { + "name": "mode", + "type": "maintenance_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "generation": { + "name": "generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entered_at": { + "name": "entered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_versions": { + "name": "object_versions", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_object_id": { + "name": "parent_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name_ciphertext": { + "name": "name_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_blind_index": { + "name": "name_blind_index", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blob_refs": { + "name": "blob_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "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": { + "object_versions_created_idx": { + "name": "object_versions_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_versions_sequence_idx": { + "name": "object_versions_sequence_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_versions_workspace_id_workspaces_id_fk": { + "name": "object_versions_workspace_id_workspaces_id_fk", + "tableFrom": "object_versions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_versions_source_device_id_devices_id_fk": { + "name": "object_versions_source_device_id_devices_id_fk", + "tableFrom": "object_versions", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "object_versions_workspace_key_fk": { + "name": "object_versions_workspace_key_fk", + "tableFrom": "object_versions", + "tableTo": "workspace_keys", + "columnsFrom": [ + "workspace_id", + "key_version" + ], + "columnsTo": [ + "workspace_id", + "key_version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "object_versions_workspace_id_object_id_revision_pk": { + "name": "object_versions_workspace_id_object_id_revision_pk", + "columns": [ + "workspace_id", + "object_id", + "revision" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_object_id": { + "name": "parent_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name_ciphertext": { + "name": "name_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_blind_index": { + "name": "name_blind_index", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_revision": { + "name": "current_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blob_refs": { + "name": "blob_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "deleted_at": { + "name": "deleted_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": { + "objects_workspace_kind_idx": { + "name": "objects_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_updated_idx": { + "name": "objects_workspace_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_sibling_name_blind_idx": { + "name": "objects_sibling_name_blind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_blind_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"objects\".\"deleted_at\" is null and \"objects\".\"name_blind_index\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_workspace_key_fk": { + "name": "objects_workspace_key_fk", + "tableFrom": "objects", + "tableTo": "workspace_keys", + "columnsFrom": [ + "workspace_id", + "key_version" + ], + "columnsTo": [ + "workspace_id", + "key_version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "objects_workspace_id_object_id_pk": { + "name": "objects_workspace_id_object_id_pk", + "columns": [ + "workspace_id", + "object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operations": { + "name": "operations", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_revision": { + "name": "result_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "result_sequence": { + "name": "result_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "operations_created_idx": { + "name": "operations_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operations_workspace_id_workspaces_id_fk": { + "name": "operations_workspace_id_workspaces_id_fk", + "tableFrom": "operations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operations_source_device_id_devices_id_fk": { + "name": "operations_source_device_id_devices_id_fk", + "tableFrom": "operations", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "operations_workspace_id_operation_id_pk": { + "name": "operations_workspace_id_operation_id_pk", + "columns": [ + "workspace_id", + "operation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_messages": { + "name": "outbox_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_or_event": { + "name": "template_or_event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_ref": { + "name": "recipient_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload_version": { + "name": "payload_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "secret_payload_ref": { + "name": "secret_payload_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "outbox_message_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_messages_idempotency_unique": { + "name": "outbox_messages_idempotency_unique", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_messages_claim_idx": { + "name": "outbox_messages_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_acceptances": { + "name": "policy_acceptances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_snapshot": { + "name": "subject_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "policy_document_id": { + "name": "policy_document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_prefix_hash": { + "name": "ip_prefix_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent_family": { + "name": "user_agent_family", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence_version": { + "name": "evidence_version", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "policy_acceptances_subject_idx": { + "name": "policy_acceptances_subject_idx", + "columns": [ + { + "expression": "subject_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "policy_acceptances_account_id_accounts_id_fk": { + "name": "policy_acceptances_account_id_accounts_id_fk", + "tableFrom": "policy_acceptances", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "policy_acceptances_policy_document_id_policy_documents_id_fk": { + "name": "policy_acceptances_policy_document_id_policy_documents_id_fk", + "tableFrom": "policy_acceptances", + "tableTo": "policy_documents", + "columnsFrom": [ + "policy_document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.policy_documents": { + "name": "policy_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_ref": { + "name": "content_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonicalization_version": { + "name": "canonicalization_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "requires_reacceptance": { + "name": "requires_reacceptance", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "policy_documents_version_locale_unique": { + "name": "policy_documents_version_locale_unique", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locale", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_buckets": { + "name": "rate_limit_buckets", + "schema": "", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_key": { + "name": "rate_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "hits": { + "name": "hits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "rate_limit_buckets_expiry_idx": { + "name": "rate_limit_buckets_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rate_limit_buckets_scope_rate_key_window_start_pk": { + "name": "rate_limit_buckets_scope_rate_key_window_start_pk", + "columns": [ + "scope", + "rate_key", + "window_start" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "issued_instance_auth_epoch": { + "name": "issued_instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_request_id": { + "name": "rotation_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rotation_response_ciphertext": { + "name": "rotation_response_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_response_expires_at": { + "name": "rotation_response_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_device_idx": { + "name": "refresh_tokens_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_account_id_accounts_id_fk": { + "name": "refresh_tokens_account_id_accounts_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refresh_tokens_device_id_devices_id_fk": { + "name": "refresh_tokens_device_id_devices_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registration_invitation_uses": { + "name": "registration_invitation_uses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invitation_id": { + "name": "invitation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "registration_invitation_uses_account_unique": { + "name": "registration_invitation_uses_account_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "registration_invitation_uses_invitation_idx": { + "name": "registration_invitation_uses_invitation_idx", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "registration_invitation_uses_invitation_id_registration_invitations_id_fk": { + "name": "registration_invitation_uses_invitation_id_registration_invitations_id_fk", + "tableFrom": "registration_invitation_uses", + "tableTo": "registration_invitations", + "columnsFrom": [ + "invitation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "registration_invitation_uses_account_id_accounts_id_fk": { + "name": "registration_invitation_uses_account_id_accounts_id_fk", + "tableFrom": "registration_invitation_uses", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registration_invitations": { + "name": "registration_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_key_id": { + "name": "token_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hint": { + "name": "token_hint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "invitation_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_snapshot": { + "name": "creator_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "bound_email_normalized": { + "name": "bound_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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 + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replaces_invitation_id": { + "name": "replaces_invitation_id", + "type": "uuid", + "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": { + "registration_invitations_token_hash_unique": { + "name": "registration_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "registration_invitations_active_idx": { + "name": "registration_invitations_active_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "registration_invitations_use_count_check": { + "name": "registration_invitations_use_count_check", + "value": "\"registration_invitations\".\"max_uses\" > 0 and \"registration_invitations\".\"use_count\" >= 0 and \"registration_invitations\".\"use_count\" <= \"registration_invitations\".\"max_uses\"" + } + }, + "isRLSEnabled": false + }, + "public.restore_credential_reviews": { + "name": "restore_credential_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "restore_marker_id": { + "name": "restore_marker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operator_kind": { + "name": "operator_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local-operator'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "restore_credential_reviews_marker_account_unique": { + "name": "restore_credential_reviews_marker_account_unique", + "columns": [ + { + "expression": "restore_marker_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "restore_credential_reviews_restore_marker_id_restore_markers_id_fk": { + "name": "restore_credential_reviews_restore_marker_id_restore_markers_id_fk", + "tableFrom": "restore_credential_reviews", + "tableTo": "restore_markers", + "columnsFrom": [ + "restore_marker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "restore_credential_reviews_account_id_accounts_id_fk": { + "name": "restore_credential_reviews_account_id_accounts_id_fk", + "tableFrom": "restore_credential_reviews", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restore_drills": { + "name": "restore_drills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_run_id": { + "name": "backup_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "restore_drill_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "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": {}, + "foreignKeys": { + "restore_drills_backup_run_id_backup_runs_id_fk": { + "name": "restore_drills_backup_run_id_backup_runs_id_fk", + "tableFrom": "restore_drills", + "tableTo": "backup_runs", + "columnsFrom": [ + "backup_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restore_markers": { + "name": "restore_markers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "restore_marker_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "old_sync_epoch": { + "name": "old_sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "new_sync_epoch": { + "name": "new_sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "restored_through_sequence_by_workspace": { + "name": "restored_through_sequence_by_workspace", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sanitation_status": { + "name": "sanitation_status", + "type": "restore_sanitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "auth_epoch_after": { + "name": "auth_epoch_after", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "bootstrap_token_cutoff": { + "name": "bootstrap_token_cutoff", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bootstrap_reissue_required": { + "name": "bootstrap_reissue_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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "restore_markers_new_epoch_unique": { + "name": "restore_markers_new_epoch_unique", + "columns": [ + { + "expression": "new_sync_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.risk_events": { + "name": "risk_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "identity_hash": { + "name": "identity_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_prefix_hash": { + "name": "ip_prefix_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent_family": { + "name": "user_agent_family", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_codes": { + "name": "reason_codes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "risk_events_account_created_idx": { + "name": "risk_events_account_created_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "risk_events_event_created_idx": { + "name": "risk_events_event_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "risk_events_account_id_accounts_id_fk": { + "name": "risk_events_account_id_accounts_id_fk", + "tableFrom": "risk_events", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.risk_provider_events": { + "name": "risk_provider_events", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature_verified_at": { + "name": "signature_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload_redacted": { + "name": "payload_redacted", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "risk_provider_event_status", + "typeSchema": "public", + "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": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "risk_provider_events_claim_idx": { + "name": "risk_provider_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "risk_provider_events_provider_provider_event_id_pk": { + "name": "risk_provider_events_provider_provider_event_id_pk", + "columns": [ + "provider", + "provider_event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.risk_restrictions": { + "name": "risk_restrictions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_type": { + "name": "subject_type", + "type": "risk_restriction_subject_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject_ref": { + "name": "subject_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "risk_restriction_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "risk_restriction_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "risk_restriction_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_staff_id": { + "name": "created_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_by_staff_id": { + "name": "revoked_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "risk_restrictions_active_unique": { + "name": "risk_restrictions_active_unique", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"risk_restrictions\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "risk_restrictions_subject_idx": { + "name": "risk_restrictions_subject_idx", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "risk_restrictions_created_by_staff_id_staff_principals_id_fk": { + "name": "risk_restrictions_created_by_staff_id_staff_principals_id_fk", + "tableFrom": "risk_restrictions", + "tableTo": "staff_principals", + "columnsFrom": [ + "created_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "risk_restrictions_revoked_by_staff_id_staff_principals_id_fk": { + "name": "risk_restrictions_revoked_by_staff_id_staff_principals_id_fk", + "tableFrom": "risk_restrictions", + "tableTo": "staff_principals", + "columnsFrom": [ + "revoked_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_metadata": { + "name": "server_metadata", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_principals": { + "name": "staff_principals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "external_issuer": { + "name": "external_issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_subject": { + "name": "external_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_login": { + "name": "local_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "local_password_hash": { + "name": "local_password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_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": { + "staff_principals_issuer_subject_unique": { + "name": "staff_principals_issuer_subject_unique", + "columns": [ + { + "expression": "external_issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_principals_local_login_unique": { + "name": "staff_principals_local_login_unique", + "columns": [ + { + "expression": "local_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"staff_principals\".\"local_login\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_principals_active_idx": { + "name": "staff_principals_active_idx", + "columns": [ + { + "expression": "disabled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_role_assignments": { + "name": "staff_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_key": { + "name": "role_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by_staff_id": { + "name": "assigned_by_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_role_assignments_staff_role_idx": { + "name": "staff_role_assignments_staff_role_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "staff_role_assignments_active_idx": { + "name": "staff_role_assignments_active_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_role_assignments_staff_id_staff_principals_id_fk": { + "name": "staff_role_assignments_staff_id_staff_principals_id_fk", + "tableFrom": "staff_role_assignments", + "tableTo": "staff_principals", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "staff_role_assignments_assigned_by_staff_id_staff_principals_id_fk": { + "name": "staff_role_assignments_assigned_by_staff_id_staff_principals_id_fk", + "tableFrom": "staff_role_assignments", + "tableTo": "staff_principals", + "columnsFrom": [ + "assigned_by_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_sessions": { + "name": "staff_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auth_strength": { + "name": "auth_strength", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "csrf_token_hash": { + "name": "csrf_token_hash", + "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()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "staff_sessions_active_idx": { + "name": "staff_sessions_active_idx", + "columns": [ + { + "expression": "staff_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_sessions_staff_id_staff_principals_id_fk": { + "name": "staff_sessions_staff_id_staff_principals_id_fk", + "tableFrom": "staff_sessions", + "tableTo": "staff_principals", + "columnsFrom": [ + "staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.step_up_grants": { + "name": "step_up_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "digest_key_id": { + "name": "digest_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "step_up_actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action_audience": { + "name": "action_audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_methods": { + "name": "auth_methods", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "step_up_grants_token_digest_unique": { + "name": "step_up_grants_token_digest_unique", + "columns": [ + { + "expression": "token_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "step_up_grants_actor_session_expiry_idx": { + "name": "step_up_grants_actor_session_expiry_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_cases": { + "name": "support_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subject_hash": { + "name": "subject_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_snapshot": { + "name": "account_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "category": { + "name": "category", + "type": "support_case_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "support_case_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "status": { + "name": "status", + "type": "support_case_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "support_case_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "assigned_staff_id": { + "name": "assigned_staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assigned_staff_snapshot": { + "name": "assigned_staff_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "support_cases_account_updated_idx": { + "name": "support_cases_account_updated_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_cases_queue_idx": { + "name": "support_cases_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_cases_account_id_accounts_id_fk": { + "name": "support_cases_account_id_accounts_id_fk", + "tableFrom": "support_cases", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "support_cases_assigned_staff_id_staff_principals_id_fk": { + "name": "support_cases_assigned_staff_id_staff_principals_id_fk", + "tableFrom": "support_cases", + "tableTo": "staff_principals", + "columnsFrom": [ + "assigned_staff_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.support_diagnostic_grants": { + "name": "support_diagnostic_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_version": { + "name": "scope_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_ciphertext": { + "name": "snapshot_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_key_id": { + "name": "snapshot_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_encryption_version": { + "name": "snapshot_encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "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 + }, + "deleted_at": { + "name": "deleted_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": { + "support_diagnostic_grants_case_idx": { + "name": "support_diagnostic_grants_case_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_diagnostic_grants_account_idx": { + "name": "support_diagnostic_grants_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_diagnostic_grants_case_id_support_cases_id_fk": { + "name": "support_diagnostic_grants_case_id_support_cases_id_fk", + "tableFrom": "support_diagnostic_grants", + "tableTo": "support_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "support_diagnostic_grants_account_id_accounts_id_fk": { + "name": "support_diagnostic_grants_account_id_accounts_id_fk", + "tableFrom": "support_diagnostic_grants", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_diagnostic_grants_scope_check": { + "name": "support_diagnostic_grants_scope_check", + "value": "length(\"support_diagnostic_grants\".\"scope_version\") > 0" + }, + "support_diagnostic_grants_encryption_version_check": { + "name": "support_diagnostic_grants_encryption_version_check", + "value": "\"support_diagnostic_grants\".\"snapshot_encryption_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_type": { + "name": "author_type", + "type": "support_message_author_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_ref": { + "name": "author_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_snapshot": { + "name": "author_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "support_message_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'customer'" + }, + "body_ciphertext": { + "name": "body_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_key_id": { + "name": "body_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_encryption_version": { + "name": "body_encryption_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body_format": { + "name": "body_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'plain'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "support_messages_case_author_idempotency_unique": { + "name": "support_messages_case_author_idempotency_unique", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_messages_case_created_idx": { + "name": "support_messages_case_created_idx", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_case_id_support_cases_id_fk": { + "name": "support_messages_case_id_support_cases_id_fk", + "tableFrom": "support_messages", + "tableTo": "support_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_messages_encryption_version_check": { + "name": "support_messages_encryption_version_check", + "value": "\"support_messages\".\"body_encryption_version\" > 0" + }, + "support_messages_key_id_check": { + "name": "support_messages_key_id_check", + "value": "length(\"support_messages\".\"body_key_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.sync_bootstrap_objects": { + "name": "sync_bootstrap_objects", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_document_sequence": { + "name": "latest_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "checkpoint_document_sequence": { + "name": "checkpoint_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "checkpoint_key_version": { + "name": "checkpoint_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext": { + "name": "checkpoint_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext_hash": { + "name": "checkpoint_ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialized_revision": { + "name": "materialized_revision", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sync_bootstrap_objects_workspace_idx": { + "name": "sync_bootstrap_objects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_bootstrap_objects_session_id_sync_bootstrap_sessions_id_fk": { + "name": "sync_bootstrap_objects_session_id_sync_bootstrap_sessions_id_fk", + "tableFrom": "sync_bootstrap_objects", + "tableTo": "sync_bootstrap_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_bootstrap_objects_workspace_id_workspaces_id_fk": { + "name": "sync_bootstrap_objects_workspace_id_workspaces_id_fk", + "tableFrom": "sync_bootstrap_objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_bootstrap_objects_session_id_object_id_pk": { + "name": "sync_bootstrap_objects_session_id_object_id_pk", + "columns": [ + "session_id", + "object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_bootstrap_sessions": { + "name": "sync_bootstrap_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "snapshot_sequence": { + "name": "snapshot_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_bootstrap_sessions_workspace_idx": { + "name": "sync_bootstrap_sessions_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_bootstrap_sessions_workspace_id_workspaces_id_fk": { + "name": "sync_bootstrap_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "sync_bootstrap_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_checkpoints": { + "name": "sync_checkpoints", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "covers_document_sequence": { + "name": "covers_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_sequence": { + "name": "event_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "materialized_revision": { + "name": "materialized_revision", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_checkpoints_document_idx": { + "name": "sync_checkpoints_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_checkpoints_workspace_id_workspaces_id_fk": { + "name": "sync_checkpoints_workspace_id_workspaces_id_fk", + "tableFrom": "sync_checkpoints", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_checkpoints_source_device_id_devices_id_fk": { + "name": "sync_checkpoints_source_device_id_devices_id_fk", + "tableFrom": "sync_checkpoints", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_checkpoints_workspace_id_checkpoint_id_pk": { + "name": "sync_checkpoints_workspace_id_checkpoint_id_pk", + "columns": [ + "workspace_id", + "checkpoint_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_commands": { + "name": "sync_commands", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "command_id": { + "name": "command_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_epoch": { + "name": "sync_epoch", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_commands_created_idx": { + "name": "sync_commands_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_commands_workspace_id_workspaces_id_fk": { + "name": "sync_commands_workspace_id_workspaces_id_fk", + "tableFrom": "sync_commands", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_commands_source_device_id_devices_id_fk": { + "name": "sync_commands_source_device_id_devices_id_fk", + "tableFrom": "sync_commands", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_commands_workspace_id_command_id_pk": { + "name": "sync_commands_workspace_id_command_id_pk", + "columns": [ + "workspace_id", + "command_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_conflicts": { + "name": "sync_conflicts", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conflict_id": { + "name": "conflict_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "conflict_type": { + "name": "conflict_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unresolved'" + }, + "expected_revision": { + "name": "expected_revision", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "expected_document_sequence": { + "name": "expected_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_sequence": { + "name": "created_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resolved_sequence": { + "name": "resolved_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "resolved_by_device_id": { + "name": "resolved_by_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "sync_conflicts_status_idx": { + "name": "sync_conflicts_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_conflicts_object_idx": { + "name": "sync_conflicts_object_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_conflicts_workspace_id_workspaces_id_fk": { + "name": "sync_conflicts_workspace_id_workspaces_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_conflicts_resolved_by_device_id_devices_id_fk": { + "name": "sync_conflicts_resolved_by_device_id_devices_id_fk", + "tableFrom": "sync_conflicts", + "tableTo": "devices", + "columnsFrom": [ + "resolved_by_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_conflicts_workspace_id_conflict_id_pk": { + "name": "sync_conflicts_workspace_id_conflict_id_pk", + "columns": [ + "workspace_id", + "conflict_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_device_cursors": { + "name": "sync_device_cursors", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "acknowledged_sequence": { + "name": "acknowledged_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_device_cursors_updated_idx": { + "name": "sync_device_cursors_updated_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_device_cursors_workspace_id_workspaces_id_fk": { + "name": "sync_device_cursors_workspace_id_workspaces_id_fk", + "tableFrom": "sync_device_cursors", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_device_cursors_device_id_devices_id_fk": { + "name": "sync_device_cursors_device_id_devices_id_fk", + "tableFrom": "sync_device_cursors", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_device_cursors_workspace_id_device_id_pk": { + "name": "sync_device_cursors_workspace_id_device_id_pk", + "columns": [ + "workspace_id", + "device_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_documents": { + "name": "sync_documents", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "object_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "latest_document_sequence": { + "name": "latest_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "checkpoint_document_sequence": { + "name": "checkpoint_document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "checkpoint_key_version": { + "name": "checkpoint_key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext": { + "name": "checkpoint_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkpoint_ciphertext_hash": { + "name": "checkpoint_ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialized_revision": { + "name": "materialized_revision", + "type": "bigint", + "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": { + "sync_documents_object_unique": { + "name": "sync_documents_object_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_documents_workspace_id_workspaces_id_fk": { + "name": "sync_documents_workspace_id_workspaces_id_fk", + "tableFrom": "sync_documents", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_documents_workspace_id_document_id_pk": { + "name": "sync_documents_workspace_id_document_id_pk", + "columns": [ + "workspace_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_events": { + "name": "sync_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "command_id": { + "name": "command_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "document_sequence": { + "name": "document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_events_workspace_sequence_unique": { + "name": "sync_events_workspace_sequence_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_workspace_event_unique": { + "name": "sync_events_workspace_event_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_document_idx": { + "name": "sync_events_document_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_events_created_idx": { + "name": "sync_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_events_workspace_id_workspaces_id_fk": { + "name": "sync_events_workspace_id_workspaces_id_fk", + "tableFrom": "sync_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_events_source_device_id_devices_id_fk": { + "name": "sync_events_source_device_id_devices_id_fk", + "tableFrom": "sync_events", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_resource_bindings": { + "name": "sync_resource_bindings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_object_id": { + "name": "owner_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_revision": { + "name": "owner_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resource_object_id": { + "name": "resource_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_revision": { + "name": "resource_revision", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_resource_bindings_resource_idx": { + "name": "sync_resource_bindings_resource_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_resource_bindings_owner_idx": { + "name": "sync_resource_bindings_owner_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_resource_bindings_workspace_id_workspaces_id_fk": { + "name": "sync_resource_bindings_workspace_id_workspaces_id_fk", + "tableFrom": "sync_resource_bindings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_resource_bindings_owner_version_fk": { + "name": "sync_resource_bindings_owner_version_fk", + "tableFrom": "sync_resource_bindings", + "tableTo": "object_versions", + "columnsFrom": [ + "workspace_id", + "owner_object_id", + "owner_revision" + ], + "columnsTo": [ + "workspace_id", + "object_id", + "revision" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_resource_bindings_resource_version_fk": { + "name": "sync_resource_bindings_resource_version_fk", + "tableFrom": "sync_resource_bindings", + "tableTo": "object_versions", + "columnsFrom": [ + "workspace_id", + "resource_object_id", + "resource_revision" + ], + "columnsTo": [ + "workspace_id", + "object_id", + "revision" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_resource_bindings_workspace_id_owner_object_id_owner_revision_resource_object_id_pk": { + "name": "sync_resource_bindings_workspace_id_owner_object_id_owner_revision_resource_object_id_pk", + "columns": [ + "workspace_id", + "owner_object_id", + "owner_revision", + "resource_object_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_updates": { + "name": "sync_updates", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_sequence": { + "name": "document_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "update_id": { + "name": "update_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_sequence": { + "name": "event_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_device_id": { + "name": "source_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext_hash": { + "name": "ciphertext_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_updates_id_unique": { + "name": "sync_updates_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "update_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_updates_event_idx": { + "name": "sync_updates_event_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_updates_workspace_id_workspaces_id_fk": { + "name": "sync_updates_workspace_id_workspaces_id_fk", + "tableFrom": "sync_updates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_updates_source_device_id_devices_id_fk": { + "name": "sync_updates_source_device_id_devices_id_fk", + "tableFrom": "sync_updates", + "tableTo": "devices", + "columnsFrom": [ + "source_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sync_updates_workspace_id_document_id_document_sequence_pk": { + "name": "sync_updates_workspace_id_document_id_document_sequence_pk", + "columns": [ + "workspace_id", + "document_id", + "document_sequence" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_events": { + "name": "usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delta": { + "name": "delta", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "resulting_value": { + "name": "resulting_value", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_period": { + "name": "billing_period", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "usage_events_workspace_idempotency_unique": { + "name": "usage_events_workspace_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_events\".\"workspace_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_events_account_idempotency_unique": { + "name": "usage_events_account_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_events\".\"workspace_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_events_account_occurred_idx": { + "name": "usage_events_account_occurred_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_events_account_id_accounts_id_fk": { + "name": "usage_events_account_id_accounts_id_fk", + "tableFrom": "usage_events", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_reservations": { + "name": "usage_reservations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_upload_ref": { + "name": "provider_upload_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "usage_reservation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reserved'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "usage_reservations_workspace_source_unique": { + "name": "usage_reservations_workspace_source_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_reservations\".\"workspace_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_reservations_account_source_unique": { + "name": "usage_reservations_account_source_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_reservations\".\"workspace_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_reservations_expiry_idx": { + "name": "usage_reservations_expiry_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_reservations_account_id_accounts_id_fk": { + "name": "usage_reservations_account_id_accounts_id_fk", + "tableFrom": "usage_reservations", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web_sessions": { + "name": "web_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "csrf_token_hash": { + "name": "csrf_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "issued_instance_auth_epoch": { + "name": "issued_instance_auth_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web_sessions_token_hash_unique": { + "name": "web_sessions_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web_sessions_account_idx": { + "name": "web_sessions_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web_sessions_expiry_idx": { + "name": "web_sessions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "web_sessions_account_id_accounts_id_fk": { + "name": "web_sessions_account_id_accounts_id_fk", + "tableFrom": "web_sessions", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_key_envelopes": { + "name": "workspace_key_envelopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "envelope_type": { + "name": "envelope_type", + "type": "key_envelope_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wrapped_key": { + "name": "wrapped_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kdf_salt": { + "name": "kdf_salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kdf_params": { + "name": "kdf_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "key_envelope_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replacement_idempotency_key": { + "name": "replacement_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replacement_request_hash": { + "name": "replacement_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_key_envelopes_key_idx": { + "name": "workspace_key_envelopes_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_key_envelopes_active_recipient_unique": { + "name": "workspace_key_envelopes_active_recipient_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "envelope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"recipient_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_key_envelopes\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_key_envelopes_replacement_idempotency_unique": { + "name": "workspace_key_envelopes_replacement_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "replacement_idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_key_envelopes\".\"replacement_idempotency_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_key_envelopes_key_fk": { + "name": "workspace_key_envelopes_key_fk", + "tableFrom": "workspace_key_envelopes", + "tableTo": "workspace_keys", + "columnsFrom": [ + "workspace_id", + "key_version" + ], + "columnsTo": [ + "workspace_id", + "key_version" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_keys": { + "name": "workspace_keys", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_keys_workspace_id_workspaces_id_fk": { + "name": "workspace_keys_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_keys", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_keys_workspace_id_key_version_pk": { + "name": "workspace_keys_workspace_id_key_version_pk", + "columns": [ + "workspace_id", + "key_version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name_ciphertext": { + "name": "name_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creation_idempotency_key": { + "name": "creation_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creation_request_hash": { + "name": "creation_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "latest_sequence": { + "name": "latest_sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "0" + }, + "deleted_at": { + "name": "deleted_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": { + "workspaces_account_idx": { + "name": "workspaces_account_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_active_default_unique": { + "name": "workspaces_active_default_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspaces\".\"is_default\" = true and \"workspaces\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_account_creation_idempotency_unique": { + "name": "workspaces_account_creation_idempotency_unique", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creation_idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspaces\".\"creation_idempotency_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_account_id_accounts_id_fk": { + "name": "workspaces_account_id_accounts_id_fk", + "tableFrom": "workspaces", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_action_token_purpose": { + "name": "account_action_token_purpose", + "schema": "public", + "values": [ + "verify_email", + "reset_password", + "change_email" + ] + }, + "public.account_identity_kind": { + "name": "account_identity_kind", + "schema": "public", + "values": [ + "username", + "email" + ] + }, + "public.account_identity_state": { + "name": "account_identity_state", + "schema": "public", + "values": [ + "pending_verification", + "active", + "legacy_migration" + ] + }, + "public.account_login_claim_kind": { + "name": "account_login_claim_kind", + "schema": "public", + "values": [ + "legacy_username", + "username", + "email" + ] + }, + "public.account_subscription_status": { + "name": "account_subscription_status", + "schema": "public", + "values": [ + "incomplete", + "trialing", + "active", + "past_due", + "grace", + "paused", + "ended", + "review" + ] + }, + "public.background_job_status": { + "name": "background_job_status", + "schema": "public", + "values": [ + "pending", + "running", + "succeeded", + "dead_letter", + "cancelled" + ] + }, + "public.backup_run_status": { + "name": "backup_run_status", + "schema": "public", + "values": [ + "queued", + "preparing", + "draining", + "dumping", + "copying", + "verifying", + "ready", + "failed", + "deleting" + ] + }, + "public.billing_checkout_status": { + "name": "billing_checkout_status", + "schema": "public", + "values": [ + "pending_provider", + "open", + "completed", + "linked", + "expired", + "failed", + "reconciling" + ] + }, + "public.billing_plan_interval": { + "name": "billing_plan_interval", + "schema": "public", + "values": [ + "month", + "year" + ] + }, + "public.billing_provider_environment": { + "name": "billing_provider_environment", + "schema": "public", + "values": [ + "test", + "live" + ] + }, + "public.billing_webhook_event_status": { + "name": "billing_webhook_event_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed", + "ignored", + "failed", + "dead_letter" + ] + }, + "public.blob_state": { + "name": "blob_state", + "schema": "public", + "values": [ + "uploading", + "ready", + "deleting" + ] + }, + "public.bootstrap_credential_source": { + "name": "bootstrap_credential_source", + "schema": "public", + "values": [ + "cli", + "legacy_environment" + ] + }, + "public.change_type": { + "name": "change_type", + "schema": "public", + "values": [ + "upsert", + "delete" + ] + }, + "public.data_request_status": { + "name": "data_request_status", + "schema": "public", + "values": [ + "submitted", + "identity_check", + "queued", + "processing", + "awaiting_user", + "completed", + "rejected", + "canceled", + "held", + "failed" + ] + }, + "public.data_request_type": { + "name": "data_request_type", + "schema": "public", + "values": [ + "access", + "export", + "correct", + "delete", + "restrict", + "object" + ] + }, + "public.deletion_case_status": { + "name": "deletion_case_status", + "schema": "public", + "values": [ + "requested", + "cooling_off", + "scheduled", + "held", + "purging", + "completed", + "canceled", + "failed" + ] + }, + "public.deletion_fence_state": { + "name": "deletion_fence_state", + "schema": "public", + "values": [ + "cooling_off", + "scheduled", + "purging", + "completed", + "canceled" + ] + }, + "public.deletion_step_state": { + "name": "deletion_step_state", + "schema": "public", + "values": [ + "pending", + "running", + "completed", + "failed", + "skipped" + ] + }, + "public.deployment_mode": { + "name": "deployment_mode", + "schema": "public", + "values": [ + "hosted", + "self-hosted" + ] + }, + "public.device_authorization_status": { + "name": "device_authorization_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.entitlement_grant_source": { + "name": "entitlement_grant_source", + "schema": "public", + "values": [ + "promotion", + "support", + "migration", + "staff" + ] + }, + "public.invitation_actor_type": { + "name": "invitation_actor_type", + "schema": "public", + "values": [ + "account", + "staff", + "system" + ] + }, + "public.key_envelope_status": { + "name": "key_envelope_status", + "schema": "public", + "values": [ + "active", + "revoked" + ] + }, + "public.key_envelope_type": { + "name": "key_envelope_type", + "schema": "public", + "values": [ + "passphrase", + "recovery", + "device", + "managed" + ] + }, + "public.maintenance_mode": { + "name": "maintenance_mode", + "schema": "public", + "values": [ + "normal", + "read_only", + "write_drain", + "offline" + ] + }, + "public.object_kind": { + "name": "object_kind", + "schema": "public", + "values": [ + "note", + "folder", + "asset", + "canvas", + "record", + "tag", + "mark", + "conversation", + "memory", + "setting", + "yjs-checkpoint", + "yjs-update" + ] + }, + "public.outbox_message_status": { + "name": "outbox_message_status", + "schema": "public", + "values": [ + "pending", + "sending", + "sent", + "dead_letter", + "delivery_unknown" + ] + }, + "public.registration_policy": { + "name": "registration_policy", + "schema": "public", + "values": [ + "bootstrap", + "disabled", + "invitation", + "public" + ] + }, + "public.restore_drill_mode": { + "name": "restore_drill_mode", + "schema": "public", + "values": [ + "verify-only", + "isolated-restore", + "full-drill" + ] + }, + "public.restore_marker_mode": { + "name": "restore_marker_mode", + "schema": "public", + "values": [ + "preserve", + "clone" + ] + }, + "public.restore_sanitation_status": { + "name": "restore_sanitation_status", + "schema": "public", + "values": [ + "pending", + "running", + "complete", + "failed" + ] + }, + "public.risk_provider_event_status": { + "name": "risk_provider_event_status", + "schema": "public", + "values": [ + "pending", + "processing", + "processed", + "ignored", + "failed", + "dead_letter" + ] + }, + "public.risk_restriction_action": { + "name": "risk_restriction_action", + "schema": "public", + "values": [ + "challenge", + "lock", + "read_only", + "deny", + "review" + ] + }, + "public.risk_restriction_scope": { + "name": "risk_restriction_scope", + "schema": "public", + "values": [ + "registration", + "authentication", + "recovery", + "device", + "sync_write", + "blob", + "billing", + "all" + ] + }, + "public.risk_restriction_source": { + "name": "risk_restriction_source", + "schema": "public", + "values": [ + "automatic", + "staff", + "provider" + ] + }, + "public.risk_restriction_subject_type": { + "name": "risk_restriction_subject_type", + "schema": "public", + "values": [ + "account", + "identity", + "device", + "ip_prefix" + ] + }, + "public.self_hosted_lifecycle": { + "name": "self_hosted_lifecycle", + "schema": "public", + "values": [ + "uninitialized", + "ready" + ] + }, + "public.step_up_actor_type": { + "name": "step_up_actor_type", + "schema": "public", + "values": [ + "account", + "staff" + ] + }, + "public.support_case_category": { + "name": "support_case_category", + "schema": "public", + "values": [ + "account", + "sync", + "device", + "encryption", + "billing", + "privacy", + "abuse", + "other" + ] + }, + "public.support_case_severity": { + "name": "support_case_severity", + "schema": "public", + "values": [ + "normal", + "high", + "urgent" + ] + }, + "public.support_case_source": { + "name": "support_case_source", + "schema": "public", + "values": [ + "web", + "client", + "email", + "staff", + "external" + ] + }, + "public.support_case_status": { + "name": "support_case_status", + "schema": "public", + "values": [ + "open", + "waiting_for_support", + "waiting_for_user", + "resolved", + "closed", + "spam" + ] + }, + "public.support_message_author_type": { + "name": "support_message_author_type", + "schema": "public", + "values": [ + "account", + "staff", + "system" + ] + }, + "public.support_message_visibility": { + "name": "support_message_visibility", + "schema": "public", + "values": [ + "customer", + "internal" + ] + }, + "public.usage_reservation_status": { + "name": "usage_reservation_status", + "schema": "public", + "values": [ + "reserved", + "external_started", + "committed", + "released", + "expired", + "reconciling" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 54c4e98..b1ab09d 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -15,6 +15,27 @@ "when": 1786505769366, "tag": "0001_clean_omega_red", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1786578713623, + "tag": "0002_amazing_proemial_gods", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1786624005937, + "tag": "0003_amused_tag", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1787130000000, + "tag": "0004_workspace_collaboration", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/server/scripts/self-test.ts b/apps/server/scripts/self-test.ts index a68b1e5..bdded9f 100644 --- a/apps/server/scripts/self-test.ts +++ b/apps/server/scripts/self-test.ts @@ -2,7 +2,6 @@ import { createHash, randomUUID } from 'node:crypto' import WebSocket from 'ws' const baseUrl = normalizeOrigin(process.env.SELF_TEST_BASE_URL ?? 'http://127.0.0.1:3789') -const setupToken = process.env.SELF_TEST_SETUP_TOKEN const keepData = process.env.SELF_TEST_KEEP_DATA === 'true' const password = 'notegen-self-test-password' @@ -20,6 +19,7 @@ async function main(): Promise { const capabilities = await request('/v1/capabilities') assert(capabilities.service === 'note-gen-server', '目标地址不是 NoteGen Sync Server') assert(capabilities.protocol.minimum <= 1 && capabilities.protocol.maximum >= 1, '服务端不支持协议 v1') + assert(capabilities.registration.methods.includes('password'), '自动验收需要管理员临时启用公开注册;完成后请恢复为仅邀请或关闭') console.log(`✓ 服务可用:${capabilities.serverName} ${capabilities.serverVersion}`) console.log(`✓ 实例身份:${capabilities.instanceId}`) @@ -29,7 +29,6 @@ async function main(): Promise { const deviceA = await request('/v1/auth/register', { method: 'POST', expectedStatus: 201, - ...(setupToken === undefined ? {} : { headers: { 'x-setup-token': setupToken } }), body: { login, password, @@ -208,16 +207,7 @@ async function request( }) const text = await response.text() if (response.status !== (options.expectedStatus ?? 200)) { - let hint = '' - try { - const error = JSON.parse(text) as { code?: string } - if (error.code === 'registration_closed' && setupToken === undefined) { - hint = '\n请设置 SELF_TEST_SETUP_TOKEN;它应与服务端 SETUP_TOKEN 一致。' - } - } catch { - // Preserve the raw response below. - } - throw new Error(`${options.method ?? 'GET'} ${path} 返回 ${response.status}: ${text}${hint}`) + throw new Error(`${options.method ?? 'GET'} ${path} 返回 ${response.status}: ${text}`) } if (response.status === 204 || text.length === 0) return undefined as T return JSON.parse(text) as T @@ -272,6 +262,7 @@ interface Capabilities { serverName: string serverVersion: string protocol: { minimum: number, maximum: number } + registration: { methods: string[] } } interface Session { accessToken: string } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3b443a2..16fe348 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -16,11 +16,11 @@ import { createAccountContextRoutes } from './routes/account-context.js' import { createSetupRoutes } from './routes/setup.js' import { createInvitationRoutes } from './routes/invitations.js' import { createComplianceRoutes } from './routes/compliance.js' -import { createBillingRoutes } from './routes/billing.js' import { createCapabilitiesRoutes } from './routes/capabilities.js' import { createHealthRoutes } from './routes/health.js' import { createAuthRoutes } from './routes/auth.js' import { createWorkspaceRoutes } from './routes/workspaces.js' +import { createWorkspaceCollaborationRoutes } from './routes/workspace-collaboration.js' import { createWebDashboardRoutes } from './routes/web-dashboard.js' import { createSyncRoutes } from './routes/sync.js' import { createEventRoutes } from './routes/events.js' @@ -32,18 +32,11 @@ import { createWebEmailRoutes } from './routes/web-email.js' import { createDeviceAuthorizationRoutes } from './routes/device-authorizations.js' import { createDevicePairingRoutes } from './routes/device-pairings.js' import { createWebAdminRoutes } from './routes/web-admin.js' -import { createSupportRoutes } from './routes/support.js' import { createMailAdminRoutes } from './routes/mail-admin.js' -import { createStaffSupportRoutes } from './routes/staff-support.js' -import { createStaffLegalHoldRoutes } from './routes/staff-legal-holds.js' -import { createStaffBillingRoutes } from './routes/staff-billing.js' -import { createStaffRiskRoutes } from './routes/staff-risk.js' import { isAllowedDevelopmentWebOrigin } from './development-origin.js' import { createPostgresRateLimitStore } from './observability/postgres-rate-limit-store.js' import type { DatabaseContext } from './database/client.js' import { createInstallationRoutes } from './routes/installation.js' -import { createStaffAuthRoutes } from './routes/staff-auth.js' -import { createStaffOperationsRoutes } from './routes/staff-operations.js' export async function buildApp( config: AppConfig, @@ -57,7 +50,6 @@ export async function buildApp( 'req.headers.authorization', 'req.headers["x-setup-token"]', 'req.headers["x-step-up-token"]', - 'req.headers["x-staff-session-id"]', 'req.body.password', 'req.body.smtpPassword', 'req.body.currentPassword', @@ -102,7 +94,7 @@ export async function buildApp( credentials: config.corsOrigins.length > 0 || config.nodeEnv === 'development', methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: [ - 'authorization', 'content-type', 'x-request-id', 'x-setup-token', 'x-step-up-token', 'x-staff-session-id', 'x-csrf-token', 'range', + 'authorization', 'content-type', 'x-request-id', 'x-setup-token', 'x-step-up-token', 'x-csrf-token', 'range', ], exposedHeaders: [ 'accept-ranges', 'content-length', 'content-range', 'retry-after', @@ -126,7 +118,8 @@ export async function buildApp( }), }) await app.register(websocket, { options: { maxPayload: 4 * 1024 * 1024 } }) - registerMetrics(app, config) + registerMetrics(app, config, 'sql' in dependencies.database + ? dependencies.database as DatabaseContext : undefined) registerErrorHandler(app) app.addHook('onRequest', async (request) => { const safetyFailure = dependencies.deployment?.getSafetyFailure() @@ -186,6 +179,7 @@ export async function buildApp( 403: ErrorResponse, 404: ErrorResponse, 409: ErrorResponse, + 410: ErrorResponse, 413: ErrorResponse, 415: ErrorResponse, 416: ErrorResponse, @@ -213,37 +207,6 @@ export async function buildApp( if (dependencies.compliance !== undefined && dependencies.webSessions !== undefined) { await app.register(createComplianceRoutes(config, dependencies.compliance, dependencies.webSessions, dependencies.deletion, dependencies.stepUps)) } - if (dependencies.support !== undefined && dependencies.webSessions !== undefined - && dependencies.capabilities?.resolvePublic()['support.cases'] === true) { - await app.register(createSupportRoutes(dependencies.support, dependencies.webSessions)) - } - if (config.deploymentMode === 'hosted' && config.hostedReleaseStage === 'internal-test' - && dependencies.support !== undefined && dependencies.staffSessions !== undefined) { - await app.register(createStaffSupportRoutes(dependencies.support, dependencies.staffSessions)) - } - if (config.deploymentMode === 'hosted' && config.hostedReleaseStage === 'internal-test' - && dependencies.staff !== undefined && dependencies.staffSessions !== undefined) { - await app.register(createStaffAuthRoutes(config, dependencies.staff, dependencies.staffSessions)) - await app.register(createStaffOperationsRoutes( - dependencies.staff, - dependencies.staffSessions, - dependencies.support, - )) - } - if (config.deploymentMode === 'hosted' && config.hostedReleaseStage === 'internal-test' - && dependencies.legalHolds !== undefined && dependencies.staffSessions !== undefined) { - await app.register(createStaffLegalHoldRoutes(dependencies.legalHolds, dependencies.staffSessions)) - } - if (config.deploymentMode === 'hosted' && config.hostedReleaseStage === 'internal-test' - && dependencies.entitlements !== undefined && dependencies.staffSessions !== undefined) { - await app.register(createStaffBillingRoutes(dependencies.entitlements, dependencies.staffSessions)) - } - if (config.deploymentMode === 'hosted' && config.hostedReleaseStage === 'internal-test' && dependencies.risk !== undefined && dependencies.staffSessions !== undefined) { - await app.register(createStaffRiskRoutes(dependencies.risk, dependencies.staffSessions)) - } - if (dependencies.entitlements !== undefined && dependencies.webSessions !== undefined) { - await app.register(createBillingRoutes(dependencies.entitlements, dependencies.webSessions)) - } if (dependencies.auth !== undefined && dependencies.tokens !== undefined) { if (dependencies.deployment === undefined) throw new Error('Auth routes require DeploymentService') await app.register(createAuthRoutes(config, dependencies.auth, dependencies.tokens, dependencies.deployment, dependencies.deletion, dependencies.risk)) @@ -251,6 +214,11 @@ export async function buildApp( if (dependencies.workspaces !== undefined && dependencies.tokens !== undefined && dependencies.auth !== undefined) { await app.register(createWorkspaceRoutes(dependencies.workspaces, dependencies.tokens, dependencies.auth)) } + if (dependencies.workspaceCollaboration !== undefined && dependencies.tokens !== undefined && dependencies.auth !== undefined) { + await app.register(createWorkspaceCollaborationRoutes( + dependencies.workspaceCollaboration, dependencies.tokens, dependencies.auth, + )) + } if (dependencies.auth !== undefined && dependencies.tokens !== undefined && dependencies.deployment !== undefined) { await app.register(createAccountContextRoutes(dependencies.auth, dependencies.tokens, dependencies.deployment, dependencies.entitlements, dependencies.usage, dependencies.compliance, dependencies.risk, dependencies.usageHardEnforcementActive, dependencies.maintenanceCoordinator)) } @@ -260,8 +228,7 @@ export async function buildApp( if (dependencies.tokens !== undefined && dependencies.workspaces !== undefined && dependencies.notifier !== undefined && dependencies.auth !== undefined) { await app.register(createEventRoutes( - dependencies.tokens, dependencies.auth, dependencies.workspaces, dependencies.notifier, dependencies.maintenanceCoordinator, - dependencies.syncEpoch, + dependencies.tokens, dependencies.auth, dependencies.workspaces, dependencies.notifier, dependencies.syncEpoch, )) } if (dependencies.blobs !== undefined && dependencies.tokens !== undefined && dependencies.auth !== undefined) { diff --git a/apps/server/src/auth/service.ts b/apps/server/src/auth/service.ts index 4a38791..5a9053e 100644 --- a/apps/server/src/auth/service.ts +++ b/apps/server/src/auth/service.ts @@ -1,7 +1,7 @@ import argon2 from 'argon2' import { and, eq, isNull, ne, sql } from 'drizzle-orm' import type { DatabaseContext } from '../database/client.js' -import { accountIdentities, accountLoginClaimConflicts, accountLoginClaims, accounts, deploymentSettings, devicePairings, devices, refreshTokens, workspaces } from '../database/schema.js' +import { accountIdentities, accountLoginClaimConflicts, accountLoginClaims, accounts, deploymentSettings, devicePairings, devices, refreshTokens, syncDeviceCursors, workspaces } from '../database/schema.js' import { ApiError } from '../errors.js' import { normalizeLoginKey } from '../identity/service.js' import type { TokenService } from './tokens.js' @@ -544,7 +544,7 @@ export class AuthService { } async listDevices(accountId: string, currentDeviceId?: string) { - const rows = await this.database.db.select({ + const [rows, cursors] = await Promise.all([this.database.db.select({ id: devices.id, name: devices.name, platform: devices.platform, @@ -552,8 +552,35 @@ export class AuthService { lastSeenAt: devices.lastSeenAt, createdAt: devices.createdAt, revokedAt: devices.revokedAt, - }).from(devices).where(eq(devices.accountId, accountId)).orderBy(devices.createdAt) - return rows.map((device) => ({ ...device, current: currentDeviceId !== undefined && device.id === currentDeviceId })) + }).from(devices).where(eq(devices.accountId, accountId)).orderBy(devices.createdAt), + this.database.db.select({ + deviceId: syncDeviceCursors.deviceId, + acknowledgedSequence: syncDeviceCursors.acknowledgedSequence, + latestSequence: workspaces.latestSequence, + acknowledgedAt: syncDeviceCursors.updatedAt, + }).from(syncDeviceCursors).innerJoin(workspaces, and( + eq(workspaces.id, syncDeviceCursors.workspaceId), + eq(workspaces.accountId, accountId), + isNull(workspaces.deletedAt), + )),]) + return rows.map((device) => { + const deviceCursors = cursors.filter(cursor => cursor.deviceId === device.id) + const pendingEventCount = deviceCursors.reduce((total, cursor) => ( + total + (cursor.latestSequence > cursor.acknowledgedSequence + ? cursor.latestSequence - cursor.acknowledgedSequence : 0n) + ), 0n) + const acknowledgedAt = deviceCursors.reduce((latest, cursor) => ( + latest === null || cursor.acknowledgedAt > latest ? cursor.acknowledgedAt : latest + ), null) + return { + ...device, + current: currentDeviceId !== undefined && device.id === currentDeviceId, + syncStatus: deviceCursors.length === 0 ? 'never-acknowledged' as const + : pendingEventCount === 0n ? 'caught-up' as const : 'behind' as const, + pendingEventCount: pendingEventCount.toString(), + acknowledgedAt, + } + }) } async #issueSession(accountId: string, deviceId: string, expectedCredentialEpoch?: string): Promise { diff --git a/apps/server/src/blobs/service.ts b/apps/server/src/blobs/service.ts index f4288eb..3b45f14 100644 --- a/apps/server/src/blobs/service.ts +++ b/apps/server/src/blobs/service.ts @@ -27,7 +27,7 @@ export class BlobService { input: { blobId: string, expectedSize: string, ciphertextHash: string, expectedSyncEpoch?: string }, ) { this.#assertExpectedSyncEpoch(input.expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.create') const expectedSize = parseSize(input.expectedSize) if (expectedSize === 0n) { throw new ApiError({ code: 'blob_empty', message: 'Blob must contain at least one byte', statusCode: 400 }) @@ -149,7 +149,7 @@ export class BlobService { partNumber: number, data: Buffer, ) { - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.update') if (data.byteLength === 0 || data.byteLength > this.partBytes) { throw new ApiError({ code: 'blob_part_invalid', message: 'Blob part size is invalid', statusCode: 400 }) } @@ -192,7 +192,7 @@ export class BlobService { async complete(accountId: string, workspaceId: string, uploadId: string, expectedSyncEpoch?: string) { this.#assertExpectedSyncEpoch(expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.update') const existing = await this.#getUpload(workspaceId, uploadId) this.#assertUploadEpoch(existing.syncEpoch) if (existing.completedAt !== null) { @@ -287,7 +287,7 @@ export class BlobService { } async get(accountId: string, workspaceId: string, blobId: string) { - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') const [blob] = await this.database.db.select().from(blobs).where(and( eq(blobs.workspaceId, workspaceId), eq(blobs.blobId, blobId), eq(blobs.state, 'ready'), )).limit(1) @@ -296,7 +296,7 @@ export class BlobService { } #assertExpectedSyncEpoch(expected: string | undefined): void { - if (expected !== undefined && this.syncEpoch !== undefined && expected !== this.syncEpoch) { + if (this.syncEpoch !== undefined && expected !== this.syncEpoch) { throw new ApiError({ code: 'sync_epoch_changed', message: 'Server restore epoch changed; re-bootstrap before writing', statusCode: 409 }) } } @@ -310,7 +310,7 @@ export class BlobService { } async uploadStatus(accountId: string, workspaceId: string, uploadId: string) { - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') const upload = await this.#getUpload(workspaceId, uploadId) return { uploadId: upload.id, @@ -339,7 +339,7 @@ export class BlobService { } async abort(accountId: string, workspaceId: string, uploadId: string): Promise { - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.delete') const upload = await this.#getActiveUpload(workspaceId, uploadId) await this.storage.abortUpload(upload.storageKey, upload.providerUploadId) await this.database.db.transaction(async (tx) => { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 868ad1d..496d7f2 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -420,41 +420,16 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon /** Applies the persisted installation profile before mode-specific services are assembled. */ export function applyPersistedDeploymentProfile( config: AppConfig, - deploymentMode: 'self-hosted' | 'hosted', - hostedRegistrationPolicy: 'disabled' | 'public' = 'disabled', + deploymentMode: 'self-hosted', ): void { - const profile = deploymentMode === 'hosted' - ? { - deploymentMode, - hostedReleaseStage: 'internal-test' as const, - hostedRegistrationPolicy, - billingProvider: 'mock' as const, - billingProviderEnvironment: 'test' as const, - billingMerchantEntity: 'internal-test-only', - hostedMailProvider: 'log' as const, - usageEnforcement: 'observe' as const, - mailDriver: 'disabled' as const, - capabilitiesEnable: hostedRegistrationPolicy === 'public' - ? ['mail.delivery', 'identity.email', 'identity.emailVerification', 'identity.passwordReset', 'registration.public'] - : [], - capabilitiesDisable: [], - } - : { - deploymentMode, - hostedRegistrationPolicy: 'disabled' as const, - billingProvider: 'none' as const, - hostedMailProvider: 'none' as const, - usageEnforcement: 'disabled' as const, - } - Object.assign(config as unknown as Record, profile) - - if (deploymentMode === 'hosted') { - const ledgerPath = resolve(config.deletionLedgerPath) - if (ledgerPath === resolve(config.backupPath) - || (config.blobStorageDriver === 'filesystem' && ledgerPath === resolve(config.blobStoragePath))) { - throw new Error('Invalid server configuration: hosted deletion ledger must be separate from backup and blob storage paths') - } + const profile = { + deploymentMode, + hostedRegistrationPolicy: 'disabled' as const, + billingProvider: 'none' as const, + hostedMailProvider: 'none' as const, + usageEnforcement: 'disabled' as const, } + Object.assign(config as unknown as Record, profile) } function parseList(value: string | undefined): string[] { diff --git a/apps/server/src/database/migration-compatibility.ts b/apps/server/src/database/migration-compatibility.ts index c371b20..c545400 100644 --- a/apps/server/src/database/migration-compatibility.ts +++ b/apps/server/src/database/migration-compatibility.ts @@ -40,6 +40,11 @@ export async function adoptSquashedMigrationBaseline(database: DatabaseContext): if (migrations.missing.length > 0 || migrations.entries.length === 0) return const baseline = migrations.entries[0]! await database.sql.begin(async (transaction) => { + const [migrationTable] = await transaction>` + select to_regclass('drizzle.__drizzle_migrations')::text as table_name` + // A brand-new database has no migration ledger yet. Drizzle creates both + // the ledger and schema while applying the first migration below. + if (migrationTable?.table_name === null) return await transaction`lock table drizzle.__drizzle_migrations in exclusive mode` const applied = await transaction>` select hash, created_at::text as created_at diff --git a/apps/server/src/database/schema.ts b/apps/server/src/database/schema.ts index a0fdab2..077a582 100644 --- a/apps/server/src/database/schema.ts +++ b/apps/server/src/database/schema.ts @@ -15,6 +15,19 @@ export const serverMetadata = pgTable('server_metadata', { createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }) +/** Durable, multi-process rate-limit counters. Raw client identifiers are + * HMACed before reaching this table. */ +export const rateLimitBuckets = pgTable('rate_limit_buckets', { + scope: text('scope').notNull(), + rateKey: text('rate_key').notNull(), + windowStart: timestamp('window_start', { withTimezone: true }).notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + hits: integer('hits').notNull().default(0), +}, (table) => [ + primaryKey({ columns: [table.scope, table.rateKey, table.windowStart] }), + index('rate_limit_buckets_expiry_idx').on(table.expiresAt), +]) + export const deploymentMode = pgEnum('deployment_mode', ['hosted', 'self-hosted']) export const registrationPolicy = pgEnum('registration_policy', ['bootstrap', 'disabled', 'invitation', 'public']) export const selfHostedLifecycle = pgEnum('self_hosted_lifecycle', ['uninitialized', 'ready']) @@ -119,9 +132,13 @@ export const maintenanceState = pgTable('maintenance_state', { }) export const objectKind = pgEnum('object_kind', [ - 'note', 'folder', 'asset', 'canvas', 'record', 'tag', 'mark', 'conversation', + 'note', 'folder', 'asset', 'canvas', 'tag', 'mark', 'conversation', 'message', 'memory', 'setting', 'yjs-checkpoint', 'yjs-update', ]) +export const workspaceType = pgEnum('workspace_type', ['account-data', 'library']) +export const workspaceMemberRole = pgEnum('workspace_member_role', ['viewer', 'editor', 'manager']) +export const workspaceInvitationKind = pgEnum('workspace_invitation_kind', ['account', 'link']) +export const workspaceInvitationStatus = pgEnum('workspace_invitation_status', ['pending', 'accepted', 'revoked', 'expired']) export const changeType = pgEnum('change_type', ['upsert', 'delete']) export const blobState = pgEnum('blob_state', ['uploading', 'ready', 'deleting']) export const keyEnvelopeType = pgEnum('key_envelope_type', ['passphrase', 'recovery', 'device', 'managed']) @@ -578,6 +595,7 @@ export const devicePairings = pgTable('device_pairings', { export const workspaces = pgTable('workspaces', { id: uuid('id').primaryKey().defaultRandom(), accountId: uuid('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }), + type: workspaceType('workspace_type').notNull().default('library'), nameCiphertext: text('name_ciphertext').notNull(), creationIdempotencyKey: text('creation_idempotency_key'), creationRequestHash: text('creation_request_hash'), @@ -593,6 +611,50 @@ export const workspaces = pgTable('workspaces', { uniqueIndex('workspaces_account_creation_idempotency_unique') .on(table.accountId, table.creationIdempotencyKey) .where(sql`${table.creationIdempotencyKey} is not null`), + uniqueIndex('workspaces_account_data_unique') + .on(table.accountId) + .where(sql`${table.type} = 'account-data' and ${table.deletedAt} is null`), +]) + +/** Owners are represented by workspaces.account_id and always have every + * capability. Only accepted, non-owner members are stored here. */ +export const workspaceMembers = pgTable('workspace_members', { + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + accountId: uuid('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }), + role: workspaceMemberRole('role').notNull(), + capabilities: text('capabilities').array().notNull(), + invitedByAccountId: uuid('invited_by_account_id').notNull().references(() => accounts.id, { onDelete: 'restrict' }), + joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + primaryKey({ columns: [table.workspaceId, table.accountId] }), + index('workspace_members_account_idx').on(table.accountId, table.updatedAt), +]) + +export const workspaceInvitations = pgTable('workspace_invitations', { + id: uuid('id').primaryKey().defaultRandom(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + kind: workspaceInvitationKind('kind').notNull(), + inviteeAccountId: uuid('invitee_account_id').references(() => accounts.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash'), + tokenHint: text('token_hint'), + role: workspaceMemberRole('role').notNull(), + capabilities: text('capabilities').array().notNull(), + status: workspaceInvitationStatus('status').notNull().default('pending'), + invitedByAccountId: uuid('invited_by_account_id').notNull().references(() => accounts.id, { onDelete: 'restrict' }), + acceptedByAccountId: uuid('accepted_by_account_id').references(() => accounts.id, { onDelete: 'set null' }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + acceptedAt: timestamp('accepted_at', { withTimezone: true }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + index('workspace_invitations_workspace_idx').on(table.workspaceId, table.status, table.createdAt), + index('workspace_invitations_invitee_idx').on(table.inviteeAccountId, table.status, table.createdAt), + uniqueIndex('workspace_invitations_token_unique').on(table.tokenHash).where(sql`${table.tokenHash} is not null`), + uniqueIndex('workspace_invitations_pending_account_unique') + .on(table.workspaceId, table.inviteeAccountId) + .where(sql`${table.kind} = 'account' and ${table.status} = 'pending'`), ]) export const workspaceKeys = pgTable('workspace_keys', { @@ -821,6 +883,9 @@ export const syncCommands = pgTable('sync_commands', { commandId: uuid('command_id').notNull(), sourceDeviceId: uuid('source_device_id').notNull().references(() => devices.id), requestHash: text('request_hash').notNull(), + /** Epoch in which the command result was committed. Null denotes a legacy + * pre-fencing result and must not deduplicate a post-restore command. */ + syncEpoch: uuid('sync_epoch'), result: jsonb('result').$type>().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ @@ -851,6 +916,19 @@ export const syncEvents = pgTable('sync_events', { index('sync_events_created_idx').on(table.createdAt), ]) +/** Highest durable event sequence explicitly acknowledged by each device. + * Delivery alone is not an acknowledgement: clients advance this only after + * the corresponding inbox rows have been committed locally. */ +export const syncDeviceCursors = pgTable('sync_device_cursors', { + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + deviceId: uuid('device_id').notNull().references(() => devices.id, { onDelete: 'cascade' }), + acknowledgedSequence: bigint('acknowledged_sequence', { mode: 'bigint' }).notNull().default(sql`0`), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => [ + primaryKey({ columns: [table.workspaceId, table.deviceId] }), + index('sync_device_cursors_updated_idx').on(table.workspaceId, table.updatedAt), +]) + export const syncDocuments = pgTable('sync_documents', { workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), documentId: text('document_id').notNull(), diff --git a/apps/server/src/deployment/capabilities.ts b/apps/server/src/deployment/capabilities.ts index 63ce8fc..5f8795c 100644 --- a/apps/server/src/deployment/capabilities.ts +++ b/apps/server/src/deployment/capabilities.ts @@ -3,19 +3,17 @@ import type { DeploymentService } from './service.js' export const capabilityIds = [ 'identity.email', 'identity.emailVerification', 'identity.passwordReset', - 'registration.invitation', 'registration.public', 'risk.advanced', 'usage.enforcement', - 'billing.subscription', 'compliance.requests', 'support.cases', 'operations.webBootstrap', + 'registration.invitation', 'registration.public', 'operations.webBootstrap', 'mail.delivery', 'operations.smtpAdmin', 'operations.unifiedBackup', 'operations.upgradeAssistant', 'operations.preserveRestore', ] as const export type CapabilityId = typeof capabilityIds[number] -type DeploymentMode = 'hosted' | 'self-hosted' export interface CapabilityExplanation { id: CapabilityId available: boolean - deploymentMode: DeploymentMode + deploymentMode: 'self-hosted' requestedBy: 'enabled_override' | 'disabled_override' | 'default' | 'lifecycle' reasons: string[] dependencies: Array<{ id: CapabilityId, available: boolean }> @@ -23,33 +21,22 @@ export interface CapabilityExplanation { interface CapabilityDefinition { readonly id: CapabilityId - readonly supportedModes: readonly DeploymentMode[] - readonly defaultByMode: Readonly> readonly requires: readonly CapabilityId[] readonly overrideable: boolean - readonly availabilitySource: 'configuration' | 'lifecycle' | 'registration-policy' } -const both: readonly DeploymentMode[] = ['hosted', 'self-hosted'] -const disabled = { hosted: false, 'self-hosted': false } as const - const definitions: readonly CapabilityDefinition[] = [ - { id: 'identity.email', supportedModes: both, defaultByMode: disabled, requires: ['mail.delivery'], overrideable: true, availabilitySource: 'configuration' }, - { id: 'identity.emailVerification', supportedModes: both, defaultByMode: disabled, requires: ['identity.email'], overrideable: true, availabilitySource: 'configuration' }, - { id: 'identity.passwordReset', supportedModes: both, defaultByMode: disabled, requires: ['identity.email'], overrideable: true, availabilitySource: 'configuration' }, - { id: 'registration.invitation', supportedModes: both, defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'registration-policy' }, - { id: 'registration.public', supportedModes: both, defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'registration-policy' }, - { id: 'risk.advanced', supportedModes: ['hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'usage.enforcement', supportedModes: ['hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'billing.subscription', supportedModes: ['hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'compliance.requests', supportedModes: ['hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'support.cases', supportedModes: ['hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'operations.webBootstrap', supportedModes: ['self-hosted'], defaultByMode: disabled, requires: [], overrideable: false, availabilitySource: 'lifecycle' }, - { id: 'mail.delivery', supportedModes: both, defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'operations.smtpAdmin', supportedModes: ['self-hosted'], defaultByMode: disabled, requires: ['mail.delivery'], overrideable: true, availabilitySource: 'configuration' }, - { id: 'operations.unifiedBackup', supportedModes: ['self-hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'operations.upgradeAssistant', supportedModes: ['self-hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, - { id: 'operations.preserveRestore', supportedModes: ['self-hosted'], defaultByMode: disabled, requires: [], overrideable: true, availabilitySource: 'configuration' }, + { id: 'identity.email', requires: ['mail.delivery'], overrideable: true }, + { id: 'identity.emailVerification', requires: ['identity.email'], overrideable: true }, + { id: 'identity.passwordReset', requires: ['identity.email'], overrideable: true }, + { id: 'registration.invitation', requires: [], overrideable: true }, + { id: 'registration.public', requires: [], overrideable: true }, + { id: 'operations.webBootstrap', requires: [], overrideable: false }, + { id: 'mail.delivery', requires: [], overrideable: true }, + { id: 'operations.smtpAdmin', requires: ['mail.delivery'], overrideable: true }, + { id: 'operations.unifiedBackup', requires: [], overrideable: true }, + { id: 'operations.upgradeAssistant', requires: [], overrideable: true }, + { id: 'operations.preserveRestore', requires: [], overrideable: true }, ] const definitionById = new Map(definitions.map((definition) => [definition.id, definition])) @@ -60,35 +47,23 @@ export class CapabilityRegistry { } resolvePublic(): Record { - const mode = this.deployment.getState().deploymentMode const result = {} as Record for (const definition of definitions) { - const supported = definition.supportedModes.includes(mode) - const requested = this.requested(definition, mode) - const releaseStageAllows = this.releaseStageAllows(definition, mode) + const requested = this.requested(definition) const lifecycleAllows = definition.id !== 'operations.webBootstrap' || this.deployment.canBootstrapAdministrator() - // Backup and preserve restore are security contracts, not preview - // toggles. Until the unified writer, artifact-driven apply path and - // required client epoch fence all exist, no override may advertise them. const restoreSafetyAllows = definition.id !== 'operations.preserveRestore' && definition.id !== 'operations.unifiedBackup' - // Hosted's log sink never delivers externally. Self-hosted may expose - // the transport capability only when the reviewed SMTP adapter is - // explicitly configured; the worker is assembled from the same config. const mailDeliverySafetyAllows = definition.id !== 'mail.delivery' - || (mode === 'self-hosted' && this.config.mailDriver === 'smtp') - || (mode === 'hosted' && this.config.hostedReleaseStage === 'internal-test' && this.config.hostedMailProvider === 'log') - // The admin surface is self-hosted-only and requires the same reviewed - // SMTP adapter as delivery. Its route assembly independently checks the - // provider, while the explicit capability override controls discovery. + || this.config.mailDriver === 'smtp' const smtpAdminSafetyAllows = definition.id !== 'operations.smtpAdmin' - || (mode === 'self-hosted' && this.config.mailDriver === 'smtp') + || this.config.mailDriver === 'smtp' const policyAllows = definition.id === 'registration.public' ? this.deployment.getState().registrationPolicy === 'public' : definition.id === 'registration.invitation' ? this.deployment.getState().registrationPolicy === 'invitation' : true - result[definition.id] = supported && requested && releaseStageAllows && lifecycleAllows && restoreSafetyAllows && mailDeliverySafetyAllows && smtpAdminSafetyAllows && policyAllows + result[definition.id] = requested && lifecycleAllows && restoreSafetyAllows + && mailDeliverySafetyAllows && smtpAdminSafetyAllows && policyAllows } for (const definition of definitions) { if (!definition.requires.every((dependency) => result[dependency])) result[definition.id] = false @@ -96,66 +71,42 @@ export class CapabilityRegistry { return result } - /** Local-operator diagnostic view. It intentionally reports only policy - * decisions, never provider settings, credentials, or secret presence. */ explain(id: string): CapabilityExplanation { const definition = definitionById.get(id as CapabilityId) if (definition === undefined) throw new Error(`Unknown capability: ${id}`) - const mode = this.deployment.getState().deploymentMode const available = this.resolvePublic() const reasons: string[] = [] const requestedBy = this.requestSource(definition) - if (!definition.supportedModes.includes(mode)) reasons.push('unsupported_deployment_mode') - if (!this.requested(definition, mode)) reasons.push('not_requested') - if (!this.releaseStageAllows(definition, mode)) reasons.push('release_stage_gated') + if (!this.requested(definition)) reasons.push('not_requested') if (definition.id === 'operations.webBootstrap' && !this.deployment.canBootstrapAdministrator()) reasons.push('lifecycle_gated') if (definition.id === 'operations.unifiedBackup' || definition.id === 'operations.preserveRestore') reasons.push('restore_safety_gated') - if (definition.id === 'mail.delivery' && !(mode === 'self-hosted' && this.config.mailDriver === 'smtp') - && !(mode === 'hosted' && this.config.hostedReleaseStage === 'internal-test' && this.config.hostedMailProvider === 'log')) reasons.push('mail_delivery_adapter_unavailable') - if (definition.id === 'operations.smtpAdmin' && !(mode === 'self-hosted' && this.config.mailDriver === 'smtp')) reasons.push('smtp_admin_surface_unavailable') + if (definition.id === 'mail.delivery' && this.config.mailDriver !== 'smtp') reasons.push('mail_delivery_adapter_unavailable') + if (definition.id === 'operations.smtpAdmin' && this.config.mailDriver !== 'smtp') reasons.push('smtp_admin_surface_unavailable') if (definition.id === 'registration.public' && this.deployment.getState().registrationPolicy !== 'public') reasons.push('registration_policy_gated') if (definition.id === 'registration.invitation' && this.deployment.getState().registrationPolicy !== 'invitation') reasons.push('registration_policy_gated') const dependencies = definition.requires.map((dependency) => ({ id: dependency, available: available[dependency] })) if (dependencies.some((dependency) => !dependency.available)) reasons.push('dependency_unavailable') if (available[definition.id] && reasons.length === 0) reasons.push('available') - return { id: definition.id, available: available[definition.id], deploymentMode: mode, requestedBy, reasons, dependencies } + return { id: definition.id, available: available[definition.id], deploymentMode: 'self-hosted', requestedBy, reasons, dependencies } } - private requested(definition: CapabilityDefinition, mode: DeploymentMode): boolean { + private requested(definition: CapabilityDefinition): boolean { if (!definition.overrideable) return definition.id === 'operations.webBootstrap' if (this.config.capabilitiesEnable.includes(definition.id)) return true if (this.config.capabilitiesDisable.includes(definition.id)) return false - if (mode === 'self-hosted' && this.config.mailDriver === 'smtp' - && (definition.id === 'mail.delivery' || definition.id === 'operations.smtpAdmin')) return true - return definition.defaultByMode[mode] + return this.config.mailDriver === 'smtp' + && (definition.id === 'mail.delivery' || definition.id === 'operations.smtpAdmin') } private requestSource(definition: CapabilityDefinition): CapabilityExplanation['requestedBy'] { if (!definition.overrideable) return 'lifecycle' if (this.config.capabilitiesEnable.includes(definition.id)) return 'enabled_override' if (this.config.capabilitiesDisable.includes(definition.id)) return 'disabled_override' - if (this.config.deploymentMode === 'self-hosted' && this.config.mailDriver === 'smtp' + if (this.config.mailDriver === 'smtp' && (definition.id === 'mail.delivery' || definition.id === 'operations.smtpAdmin')) return 'enabled_override' return 'default' } - private releaseStageAllows(definition: CapabilityDefinition, mode: DeploymentMode): boolean { - if (mode !== 'hosted') return true - if (this.config.hostedReleaseStage === 'live') { - // The current support implementation is an internal-test customer case - // core only. It has no staff OIDC boundary or permission-enforced - // console, so a live config must not advertise it merely because an - // operator supplied an override. - return definition.id !== 'support.cases' - } - // Test configuration has no external delivery, payment, or actionable - // compliance surface. Its redacted LogMailProvider may be explicitly - // enabled for internal identity exercises only. Support is deliberately - // excluded from this list because its first customer case slice is - // internal-test only. - return !['billing.subscription', 'compliance.requests'].includes(definition.id) - } - private validateOverrides(): void { const enabled = new Set(this.config.capabilitiesEnable) const disabledIds = new Set(this.config.capabilitiesDisable) @@ -164,9 +115,6 @@ export class CapabilityRegistry { if (definition === undefined) throw new Error(`Unknown capability override: ${id}`) if (!definition.overrideable) throw new Error(`Capability cannot be overridden: ${id}`) if (enabled.has(id) && disabledIds.has(id)) throw new Error(`Capability is both enabled and disabled: ${id}`) - if (!definition.supportedModes.includes(this.config.deploymentMode)) { - throw new Error(`Capability is not supported for ${this.config.deploymentMode}: ${id}`) - } } } } diff --git a/apps/server/src/deployment/service.ts b/apps/server/src/deployment/service.ts index 687c466..e48cde3 100644 --- a/apps/server/src/deployment/service.ts +++ b/apps/server/src/deployment/service.ts @@ -32,7 +32,7 @@ export interface RuntimeConfiguration { } export interface DeploymentState { - deploymentMode: 'hosted' | 'self-hosted' + deploymentMode: 'self-hosted' registrationPolicy: RegistrationPolicy selfHostedLifecycle: 'uninitialized' | 'ready' | null adminRepairRequired: boolean @@ -40,7 +40,7 @@ export interface DeploymentState { runtimeConfiguration: RuntimeConfiguration } -/** Loads the immutable deployment choice made by the Web installation guide. */ +/** Loads the independent-instance configuration created by the Web installer. */ export class DeploymentService { private state: DeploymentState | undefined private configurationListening = false @@ -162,8 +162,11 @@ export class DeploymentService { } private toState(row: typeof deploymentSettings.$inferSelect): DeploymentState { + if (row.deploymentMode !== 'self-hosted') { + throw new Error('The legacy operations deployment mode is no longer supported') + } return { - deploymentMode: row.deploymentMode, + deploymentMode: 'self-hosted', registrationPolicy: row.registrationPolicy, selfHostedLifecycle: row.selfHostedLifecycle, adminRepairRequired: row.adminRepairRequired, diff --git a/apps/server/src/durable-sync/service.ts b/apps/server/src/durable-sync/service.ts index 327954f..208eb64 100644 --- a/apps/server/src/durable-sync/service.ts +++ b/apps/server/src/durable-sync/service.ts @@ -4,7 +4,7 @@ import type { DatabaseContext } from '../database/client.js' import { blobs, objectVersions, objects, syncBootstrapObjects, syncBootstrapSessions, syncCheckpoints, syncCommands, syncConflicts, syncDocuments, syncEvents, - syncResourceBindings, syncUpdates, workspaceKeys, workspaces, + syncDeviceCursors, syncResourceBindings, syncUpdates, workspaceKeys, workspaces, } from '../database/schema.js' import { ApiError } from '../errors.js' import { assertAccountWriteAllowedInTransaction } from '../compliance/deletion-fence.js' @@ -12,6 +12,43 @@ import type { ChangeNotifier } from '../sync/types.js' import type { WorkspaceService } from '../workspaces/service.js' import type { CiphertextEnvelope, SyncCommand, SyncCommandResult } from './types.js' import type { UsageService } from '../usage/service.js' +import type { WorkspaceCapability } from '../workspaces/capabilities.js' + +const cacheableRejectionCodes = new Set([ + 'request_invalid', 'object_too_large', 'ciphertext_hash_mismatch', + 'object_kind_mismatch', 'update_id_reused', 'conflict_id_reused', + 'document_identity_mismatch', 'asset_cannot_bind_resources', + 'name_blind_index_key_mismatch', +]) + +function assertCommandCapability(capabilities: readonly WorkspaceCapability[], command: SyncCommand): void { + let required: WorkspaceCapability + switch (command.type) { + case 'upsert-object': + required = command.baseRevision === null ? 'content.create' : 'content.update' + break + case 'delete-object': + case 'delete-subtree': + required = 'content.delete' + break + case 'resolve-conflict': + required = 'history.restore' + break + case 'append-update': + case 'commit-checkpoint': + case 'create-conflict': + required = 'content.update' + break + } + if (!capabilities.includes(required)) { + throw new ApiError({ + code: 'workspace_capability_denied', + message: `Workspace capability ${required} is required`, + statusCode: 403, + details: { capability: required }, + }) + } +} export class DurableSyncService { constructor( @@ -24,23 +61,98 @@ export class DurableSyncService { private readonly syncEpoch?: string, ) {} + async session( + accountId: string, + deviceId: string, + workspaceId: string, + cursorValue: string, + protocolVersion: number, + expectedSyncEpoch?: string, + ) { + if (this.syncEpoch === undefined) throw new Error('Protocol v1 requires a sync epoch') + if (expectedSyncEpoch !== undefined) this.#assertSyncEpoch(expectedSyncEpoch) + const access = await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') + const cursor = counter(cursorValue, 'cursor') + const [[workspace], [oldestEvent], [deviceCursor], keyVersions] = await Promise.all([ + this.database.db.select({ latestSequence: workspaces.latestSequence }) + .from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1), + this.database.db.select({ sequence: syncEvents.sequence }).from(syncEvents) + .where(eq(syncEvents.workspaceId, workspaceId)).orderBy(asc(syncEvents.sequence)).limit(1), + this.database.db.select({ acknowledgedSequence: syncDeviceCursors.acknowledgedSequence }) + .from(syncDeviceCursors).where(and( + eq(syncDeviceCursors.workspaceId, workspaceId), + eq(syncDeviceCursors.deviceId, deviceId), + )).limit(1), + this.database.db.select({ keyVersion: workspaceKeys.keyVersion, createdAt: workspaceKeys.createdAt }) + .from(workspaceKeys).where(eq(workspaceKeys.workspaceId, workspaceId)) + .orderBy(workspaceKeys.keyVersion), + ]) + if (workspace === undefined) { + throw new ApiError({ code: 'workspace_not_found', message: 'Workspace not found', statusCode: 404 }) + } + const cursorState = cursor > workspace.latestSequence + ? 'ahead' as const + : oldestEvent !== undefined && cursor + 1n < oldestEvent.sequence + ? 'expired' as const + : 'valid' as const + return { + protocol: { + requestedVersion: protocolVersion, + selectedVersion: 1, + compatible: protocolVersion === 1, + }, + workspace: { + id: workspaceId, + type: access.type, + role: access.role, + owner: access.owner, + capabilities: access.capabilities, + }, + cursor: { + supplied: cursorValue, + state: cursorState, + acknowledged: deviceCursor?.acknowledgedSequence.toString() ?? '0', + oldestAvailableSequence: oldestEvent?.sequence.toString() ?? null, + }, + latestSequence: workspace.latestSequence.toString(), + bootstrap: { + required: cursorState !== 'valid', + reason: cursorState === 'valid' ? null : cursorState === 'ahead' ? 'cursor_ahead' : 'cursor_expired', + }, + limits: { + maxCommandsPerBatch: 100, + maxEventsPerPage: 1_000, + maxBootstrapObjectsPerPage: 1_000, + maxDocumentUpdatesPerPage: 1_000, + maxObjectBytes: typeof this.maxObjectBytes === 'function' ? this.maxObjectBytes() : this.maxObjectBytes, + }, + keyVersions: keyVersions.map(key => ({ keyVersion: key.keyVersion, createdAt: key.createdAt })), + syncEpoch: this.syncEpoch, + } + } + async commands(accountId: string, deviceId: string, workspaceId: string, commands: SyncCommand[], expectedSyncEpoch?: string) { this.#assertSyncEpoch(expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + const access = await this.workspaces.access(accountId, workspaceId) const results: SyncCommandResult[] = [] for (const command of commands) { try { - results.push(await this.#command(accountId, deviceId, workspaceId, command)) + assertCommandCapability(access.capabilities, command) + results.push(await this.#command(access.ownerAccountId, deviceId, workspaceId, command)) } catch (error) { if (!(error instanceof ApiError)) throw error - results.push({ + const rejected: SyncCommandResult = { commandId: command.commandId, status: 'rejected', duplicate: false, code: error.code, retryable: error.retryable, ...(error.details === undefined ? {} : { details: error.details }), - }) + } + if (!error.retryable && cacheableRejectionCodes.has(error.code)) { + await this.#recordRejectedCommand(access.ownerAccountId, deviceId, workspaceId, command, rejected).catch(() => undefined) + } + results.push(rejected) } } - return { results } + return { results, ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }) } } async recordCommandIngress(accountId: string, workspaceId: string, bytes: bigint, requestId: string): Promise { @@ -49,20 +161,59 @@ export class DurableSyncService { async events(accountId: string, workspaceId: string, after: string, limit: number, expectedSyncEpoch?: string) { this.#assertSyncEpoch(expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') const cursor = counter(after, 'after') + const [workspace] = await this.database.db.select({ latestSequence: workspaces.latestSequence }) + .from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1) + const latestSequence = workspace?.latestSequence ?? 0n + if (cursor > latestSequence) throw new ApiError({ + code: 'cursor_invalid', message: 'Sync cursor is ahead of the workspace', statusCode: 409, + details: { latestSequence: latestSequence.toString() }, + }) const rows = await this.database.db.select().from(syncEvents).where(and( eq(syncEvents.workspaceId, workspaceId), gt(syncEvents.sequence, cursor), )).orderBy(asc(syncEvents.sequence)).limit(limit + 1) + if (cursor < latestSequence && (rows[0] === undefined || rows[0].sequence !== cursor + 1n)) { + throw new ApiError({ + code: 'cursor_expired', message: 'Sync cursor is older than the retained event window; bootstrap is required', + statusCode: 409, retryable: false, + details: { oldestAvailableSequence: rows[0]?.sequence.toString() ?? null, latestSequence: latestSequence.toString() }, + }) + } const hasMore = rows.length > limit const page = rows.slice(0, limit).map(serializeEvent) - const [workspace] = await this.database.db.select({ latestSequence: workspaces.latestSequence }) - .from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1) return { events: page, nextCursor: page.at(-1)?.sequence ?? after, - latestSequence: (workspace?.latestSequence ?? cursor).toString(), + latestSequence: latestSequence.toString(), hasMore, + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), + } + } + + async acknowledge(accountId: string, deviceId: string, workspaceId: string, throughValue: string, expectedSyncEpoch?: string) { + this.#assertSyncEpoch(expectedSyncEpoch) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') + const through = counter(throughValue, 'through') + const [workspace] = await this.database.db.select({ latestSequence: workspaces.latestSequence }) + .from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1) + if (workspace === undefined) throw new ApiError({ code: 'workspace_not_found', message: 'Workspace not found', statusCode: 404 }) + if (through > workspace.latestSequence) throw new ApiError({ + code: 'cursor_invalid', message: 'Acknowledgement is ahead of the workspace', statusCode: 409, + details: { latestSequence: workspace.latestSequence.toString() }, + }) + const [cursor] = await this.database.db.insert(syncDeviceCursors).values({ + workspaceId, deviceId, acknowledgedSequence: through, + }).onConflictDoUpdate({ + target: [syncDeviceCursors.workspaceId, syncDeviceCursors.deviceId], + set: { + acknowledgedSequence: sql`greatest(${syncDeviceCursors.acknowledgedSequence}, ${through})`, + updatedAt: new Date(), + }, + }).returning({ acknowledgedSequence: syncDeviceCursors.acknowledgedSequence }) + return { + acknowledgedSequence: (cursor?.acknowledgedSequence ?? through).toString(), + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), } } @@ -74,7 +225,7 @@ export class DurableSyncService { expectedSyncEpoch?: string, ) { this.#assertSyncEpoch(expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'history.view') const revision = counter(revisionValue, 'revision') const [version] = await this.database.db.select().from(objectVersions).where(and( eq(objectVersions.workspaceId, workspaceId), @@ -118,7 +269,10 @@ export class DurableSyncService { currentRevision: currentRevisions.get(row.objectId) ?? null, }) const resources = resourceRows.map(row => withCurrentRevision(row.version)) - return { object: withCurrentRevision(version), resources } + return { + object: withCurrentRevision(version), resources, + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), + } } async objectVersions( @@ -130,7 +284,7 @@ export class DurableSyncService { expectedSyncEpoch?: string, ) { this.#assertSyncEpoch(expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'history.view') const before = beforeValue === null ? null : counter(beforeValue, 'before') const rows = await this.database.db.select().from(objectVersions).where(and( eq(objectVersions.workspaceId, workspaceId), @@ -143,6 +297,7 @@ export class DurableSyncService { versions: page.map(serializeObjectVersion), nextBefore: hasMore ? page.at(-1)?.revision.toString() ?? null : null, hasMore, + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), } } @@ -155,15 +310,17 @@ export class DurableSyncService { expectedSyncEpoch?: string, ) { this.#assertSyncEpoch(expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') const session = await this.database.db.transaction(async (tx) => { await assertAccountWriteAllowedInTransaction(tx, accountId) await tx.delete(syncBootstrapSessions).where(sql`${syncBootstrapSessions.expiresAt} <= now()`) if (bootstrapId !== null) { - const [existing] = await tx.select().from(syncBootstrapSessions).where(and( + const [existing] = await tx.update(syncBootstrapSessions).set({ + expiresAt: new Date(Date.now() + 30 * 60_000), + }).where(and( eq(syncBootstrapSessions.id, bootstrapId), eq(syncBootstrapSessions.workspaceId, workspaceId), gt(syncBootstrapSessions.expiresAt, new Date()), - )).limit(1) + )).returning() if (existing === undefined) { throw new ApiError({ code: 'bootstrap_expired', message: 'Bootstrap snapshot expired; restart bootstrap', statusCode: 409, retryable: true }) } @@ -172,6 +329,16 @@ export class DurableSyncService { const [lockedWorkspace] = await tx.select({ latestSequence: workspaces.latestSequence }).from(workspaces) .where(eq(workspaces.id, workspaceId)).limit(1).for('update') if (lockedWorkspace === undefined) throw new ApiError({ code: 'workspace_not_found', message: 'Workspace not found', statusCode: 404 }) + const activeSessions = await tx.select({ id: syncBootstrapSessions.id }).from(syncBootstrapSessions).where(and( + eq(syncBootstrapSessions.workspaceId, workspaceId), gt(syncBootstrapSessions.expiresAt, new Date()), + )).limit(8) + if (activeSessions.length >= 8) throw new ApiError({ + code: 'bootstrap_capacity_exceeded', + message: 'Too many bootstrap snapshots are active for this workspace', + statusCode: 429, + retryable: true, + details: { retryAfterMilliseconds: 30_000 }, + }) const [created] = await tx.insert(syncBootstrapSessions).values({ workspaceId, snapshotSequence: lockedWorkspace.latestSequence, expiresAt: new Date(Date.now() + 30 * 60_000), @@ -240,12 +407,13 @@ export class DurableSyncService { conflicts: unresolved.map(serializeConflict), nextObjectId: hasMore ? page.at(-1)?.manifest.objectId ?? null : null, hasMore, + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), } } async documentUpdates(accountId: string, workspaceId: string, documentId: string, after: string, limit: number, expectedSyncEpoch?: string) { this.#assertSyncEpoch(expectedSyncEpoch) - await this.workspaces.assertOwned(accountId, workspaceId) + await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') const cursor = counter(after, 'after') const rows = await this.database.db.select().from(syncUpdates).where(and( eq(syncUpdates.workspaceId, workspaceId), eq(syncUpdates.documentId, documentId), @@ -257,11 +425,12 @@ export class DurableSyncService { updates: page.map(row => ({ ...row, documentSequence: row.documentSequence.toString(), eventSequence: row.eventSequence.toString() })), nextDocumentSequence: page.at(-1)?.documentSequence.toString() ?? after, hasMore, + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), } } #assertSyncEpoch(expectedSyncEpoch: string | undefined): void { - if (expectedSyncEpoch !== undefined && this.syncEpoch !== undefined && expectedSyncEpoch !== this.syncEpoch) { + if (this.syncEpoch !== undefined && expectedSyncEpoch !== this.syncEpoch) { throw new ApiError({ code: 'sync_epoch_changed', message: 'Server restore epoch changed; re-bootstrap before continuing', statusCode: 409 }) } } @@ -280,15 +449,45 @@ export class DurableSyncService { eq(syncCommands.workspaceId, workspaceId), eq(syncCommands.commandId, command.commandId), )).limit(1) if (previous !== undefined) { + if (this.syncEpoch !== undefined && previous.syncEpoch !== this.syncEpoch) { + throw new ApiError({ + code: 'command_id_reused', + message: 'Command ID belongs to another server restore epoch', + statusCode: 409, + details: { reason: 'sync_epoch_changed' }, + }) + } if (previous.requestHash !== requestHash) throw new ApiError({ code: 'command_id_reused', message: 'Command ID was reused', statusCode: 409 }) return { ...(previous.result as SyncCommandResult), duplicate: true } } + if (storageLimit !== null) await this.usage?.reconcileCurrentInTransaction(tx, accountId) const applied = await this.#apply(tx, accountId, deviceId, workspaceId, command, storageLimit) await tx.insert(syncCommands).values({ workspaceId, commandId: command.commandId, sourceDeviceId: deviceId, requestHash, + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), result: applied as unknown as Record, }) + const releasesStorage = command.type === 'delete-object' || command.type === 'delete-subtree' + || (command.type === 'resolve-conflict' && command.deleteObject === true) + if (storageLimit !== null && this.usage !== undefined && !releasesStorage) { + const exact = await this.usage.reconcileCurrentInTransaction(tx, accountId) + if (exact !== undefined + && exact.activeObjectBytes + exact.activeCrdtBytes + exact.activeBlobBytes + + exact.reservedBlobBytes + exact.retainedBytes > storageLimit) { + throw new ApiError({ + code: 'quota_exceeded', + message: 'Storage quota is exceeded', + statusCode: 409, + details: { + metric: 'storage_bytes', limit: storageLimit.toString(), + used: (exact.activeObjectBytes + exact.activeCrdtBytes + exact.activeBlobBytes + + exact.reservedBlobBytes + exact.retainedBytes).toString(), + reserved: exact.reservedBlobBytes.toString(), + }, + }) + } + } return applied }) if ((result.status === 'applied' || result.status === 'conflict') && result.sequence !== undefined) { @@ -297,6 +496,24 @@ export class DurableSyncService { return result } + async #recordRejectedCommand( + accountId: string, + deviceId: string, + workspaceId: string, + command: SyncCommand, + result: SyncCommandResult, + ): Promise { + const requestHash = createHash('sha256').update(JSON.stringify(command)).digest('base64url') + await this.database.db.transaction(async (tx) => { + await assertAccountWriteAllowedInTransaction(tx, accountId) + await tx.insert(syncCommands).values({ + workspaceId, commandId: command.commandId, sourceDeviceId: deviceId, requestHash, + ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), + result: result as unknown as Record, + }).onConflictDoNothing() + }) + } + async #apply(tx: SyncTransaction, accountId: string, deviceId: string, workspaceId: string, command: SyncCommand, storageLimit: bigint | null): Promise { if (command.type === 'upsert-object' || command.type === 'delete-object') { const [current] = await tx.select().from(objects).where(and(eq(objects.workspaceId, workspaceId), eq(objects.objectId, command.objectId))).limit(1) @@ -350,6 +567,7 @@ export class DurableSyncService { } } if (command.type === 'delete-object') { + if (command.kind === 'folder') await this.#assertFolderEmpty(tx, workspaceId, command.objectId) if (command.kind === 'asset') { const bindings = await tx.select({ ownerObjectId: syncResourceBindings.ownerObjectId, @@ -536,21 +754,26 @@ export class DurableSyncService { this.#validateCiphertext(command.conflictCiphertext, command.conflictCiphertextHash) await this.#assertKeyAndBlobs(tx, workspaceId, command.conflictKeyVersion, []) const currentObjects = await tx.select().from(objects).where(eq(objects.workspaceId, workspaceId)) - const root = currentObjects.find(object => object.objectId === command.rootObjectId) + const objectById = new Map(currentObjects.map(object => [object.objectId, object])) + const childrenByParent = new Map() + for (const object of currentObjects) { + if (object.parentObjectId === null) continue + const children = childrenByParent.get(object.parentObjectId) ?? [] + children.push(object) + childrenByParent.set(object.parentObjectId, children) + } + const root = objectById.get(command.rootObjectId) if (root === undefined || root.kind !== 'folder') { throw new ApiError({ code: 'subtree_root_invalid', message: 'Subtree root must be an active folder', statusCode: 409 }) } if (root.deletedAt !== null) { const descendants = new Set([root.objectId]) - let changed = true - while (changed) { - changed = false - for (const object of currentObjects) { - if (object.parentObjectId !== null && descendants.has(object.parentObjectId) - && !descendants.has(object.objectId)) { - descendants.add(object.objectId) - changed = true - } + const pending = [root.objectId] + while (pending.length > 0) { + for (const child of childrenByParent.get(pending.pop()!) ?? []) { + if (descendants.has(child.objectId)) continue + descendants.add(child.objectId) + pending.push(child.objectId) } } const activeDescendant = currentObjects.some(object => ( @@ -566,15 +789,12 @@ export class DurableSyncService { return { commandId: command.commandId, status: 'applied', duplicate: true } } const descendants = new Set([root.objectId]) - let changed = true - while (changed) { - changed = false - for (const object of currentObjects) { - if (object.deletedAt === null && object.parentObjectId !== null - && descendants.has(object.parentObjectId) && !descendants.has(object.objectId)) { - descendants.add(object.objectId) - changed = true - } + const pending = [root.objectId] + while (pending.length > 0) { + for (const child of childrenByParent.get(pending.pop()!) ?? []) { + if (child.deletedAt !== null || descendants.has(child.objectId)) continue + descendants.add(child.objectId) + pending.push(child.objectId) } } const requestedIds = new Set(command.objects.map(object => object.objectId)) @@ -586,7 +806,10 @@ export class DurableSyncService { }) } const requested = new Map(command.objects.map(object => [object.objectId, object])) - const documentRows = await tx.select().from(syncDocuments).where(eq(syncDocuments.workspaceId, workspaceId)) + const documentRows = await tx.select().from(syncDocuments).where(and( + eq(syncDocuments.workspaceId, workspaceId), inArray(syncDocuments.objectId, [...descendants]), + )) + const documentByObjectId = new Map(documentRows.map(document => [document.objectId, document])) let editConflict = false for (const current of currentObjects.filter(object => descendants.has(object.objectId))) { const item = requested.get(current.objectId) @@ -596,7 +819,7 @@ export class DurableSyncService { } this.#validateEnvelope(item) await this.#assertKeyAndBlobs(tx, workspaceId, item.keyVersion, item.blobRefs) - const document = documentRows.find(row => row.objectId === current.objectId) + const document = documentByObjectId.get(current.objectId) if ((document?.latestDocumentSequence ?? 0n) > counter(item.expectedDocumentSequence, 'expectedDocumentSequence')) { editConflict = true } @@ -626,7 +849,7 @@ export class DurableSyncService { while (parentId !== null && descendants.has(parentId) && !seen.has(parentId)) { seen.add(parentId) value += 1 - parentId = currentObjects.find(candidate => candidate.objectId === parentId)?.parentObjectId ?? null + parentId = objectById.get(parentId)?.parentObjectId ?? null } return value } @@ -822,10 +1045,11 @@ export class DurableSyncService { throw new ApiError({ code: 'request_invalid', message: 'Delete conflict resolution cannot also restore content', statusCode: 400 }) } if (command.requiresCommandId !== undefined) { - const [required] = await tx.select({ result: syncCommands.result }).from(syncCommands).where(and( + const [required] = await tx.select({ result: syncCommands.result, syncEpoch: syncCommands.syncEpoch }).from(syncCommands).where(and( eq(syncCommands.workspaceId, workspaceId), eq(syncCommands.commandId, command.requiresCommandId), )).limit(1) - if (required === undefined || required.result.status !== 'applied') { + if (required === undefined || required.result.status !== 'applied' + || (this.syncEpoch !== undefined && required.syncEpoch !== this.syncEpoch)) { throw new ApiError({ code: 'required_command_not_applied', message: 'Required command was not applied', statusCode: 409, @@ -1011,6 +1235,7 @@ export class DurableSyncService { && object.currentRevision !== conflict.expectedRevision)) { return { commandId: command.commandId, status: 'conflict', duplicate: false, code: 'conflict_changed' } } + if (object.kind === 'folder') await this.#assertFolderEmpty(tx, workspaceId, object.objectId) const revision = object.currentRevision + 1n const deleteSequence = await nextSequence(tx, workspaceId) await this.usage?.applyCurrentObject(tx, { @@ -1113,6 +1338,19 @@ export class DurableSyncService { } } + async #assertFolderEmpty(tx: SyncTransaction, workspaceId: string, folderObjectId: string): Promise { + const [child] = await tx.select({ objectId: objects.objectId }).from(objects).where(and( + eq(objects.workspaceId, workspaceId), eq(objects.parentObjectId, folderObjectId), isNull(objects.deletedAt), + )).limit(1) + if (child !== undefined) throw new ApiError({ + code: 'folder_not_empty', + message: 'A folder with active children must be deleted with the atomic subtree command', + statusCode: 409, + retryable: false, + details: { childObjectId: child.objectId }, + }) + } + async #pruneCoveredUpdates(tx: SyncTransaction, workspaceId: string, documentId: string, covers: bigint): Promise { const [activeBootstrap] = await tx.select({ id: syncBootstrapSessions.id }).from(syncBootstrapSessions).where(and( eq(syncBootstrapSessions.workspaceId, workspaceId), gt(syncBootstrapSessions.expiresAt, new Date()), diff --git a/apps/server/src/identity/email-service.ts b/apps/server/src/identity/email-service.ts index 963ec8f..58265be 100644 --- a/apps/server/src/identity/email-service.ts +++ b/apps/server/src/identity/email-service.ts @@ -239,7 +239,7 @@ export class EmailIdentityService { to: email, template, locale: this.config.mailDefaultLocale, - variables: { actionUrl: `${this.config.publicBaseUrl.replace(/\/$/, '')}/account/${purpose === 'verify_email' ? 'verify-email' : 'reset-password'}?token=${encodeURIComponent(issued.token)}` }, + variables: { actionUrl: `${this.config.webPublicBaseUrl.replace(/\/$/, '')}/account/${purpose === 'verify_email' ? 'verify-email' : 'reset-password'}/?token=${encodeURIComponent(issued.token)}` }, }, issued.expiresAt) await this.mailOutbox.enqueueInTransaction(tx, { template, diff --git a/apps/server/src/installation/service.ts b/apps/server/src/installation/service.ts index 38f5da0..abad370 100644 --- a/apps/server/src/installation/service.ts +++ b/apps/server/src/installation/service.ts @@ -1,5 +1,5 @@ import argon2 from 'argon2' -import { eq, sql } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import type { AppConfig } from '../config.js' import type { DatabaseContext } from '../database/client.js' import { @@ -8,27 +8,22 @@ import { accounts, adminAuditLogs, deploymentSettings, + maintenanceState, staffPrincipals, - staffRoleAssignments, } from '../database/schema.js' import { ApiError } from '../errors.js' import { normalizeLoginKey } from '../identity/service.js' -import { normalizeLocalStaffLogin } from '../staff/service.js' export type InstallationDeploymentMode = 'self-hosted' | 'hosted' -export type HostedRegistrationPolicy = 'disabled' | 'public' export interface InstallationStatus { installationRequired: boolean activationPending: boolean - deploymentMode: InstallationDeploymentMode | null serverName: string } export interface CompleteInstallationInput { - deploymentMode: InstallationDeploymentMode serverName: string - hostedRegistrationPolicy?: HostedRegistrationPolicy administrator: { login: string, password: string } } @@ -42,7 +37,6 @@ export class InstallationService { async status(): Promise { const [settings] = await this.database.db.select({ - deploymentMode: deploymentSettings.deploymentMode, runtimeConfiguration: deploymentSettings.runtimeConfiguration, }).from(deploymentSettings).where(eq(deploymentSettings.id, true)).limit(1) const serverName = typeof settings?.runtimeConfiguration.serverName === 'string' @@ -51,14 +45,13 @@ export class InstallationService { return { installationRequired: settings === undefined, activationPending: settings !== undefined && this.installationOnly, - deploymentMode: settings?.deploymentMode ?? null, serverName, } } async persistedSettings(): Promise<{ deploymentMode: InstallationDeploymentMode - registrationPolicy: HostedRegistrationPolicy | 'bootstrap' | 'invitation' + registrationPolicy: 'bootstrap' | 'disabled' | 'invitation' | 'public' } | undefined> { const [settings] = await this.database.db.select({ deploymentMode: deploymentSettings.deploymentMode, @@ -68,8 +61,35 @@ export class InstallationService { return settings } + /** Converts the removed hosted/internal-test marker into the single + * independent-instance model. This preserves accounts, registration policy, + * runtime configuration, workspaces and all synchronized data. */ + async migrateLegacyOperationsInstallation(): Promise { + return await this.database.db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext('notegen-installation-mode-migration'))`) + const [settings] = await tx.select().from(deploymentSettings) + .where(eq(deploymentSettings.id, true)).limit(1).for('update') + if (settings === undefined || settings.deploymentMode === 'self-hosted') return false + const [activeAdmin] = await tx.select({ id: accounts.id }).from(accounts).where(and( + eq(accounts.isAdmin, true), isNull(accounts.suspendedAt), isNull(accounts.disabledAt), + )).limit(1) + await tx.update(deploymentSettings).set({ + deploymentMode: 'self-hosted', + selfHostedLifecycle: 'ready', + adminRepairRequired: activeAdmin === undefined, + initializedAt: settings.initializedAt ?? new Date(), + initializedByAccountId: settings.initializedByAccountId ?? activeAdmin?.id ?? null, + configurationRevision: sql`${deploymentSettings.configurationRevision} + 1`, + updatedAt: new Date(), + }).where(and( + eq(deploymentSettings.id, true), + eq(deploymentSettings.deploymentMode, settings.deploymentMode), + )) + return true + }) + } + async complete(input: CompleteInstallationInput): Promise<{ - deploymentMode: InstallationDeploymentMode serverName: string activationPending: true }> { @@ -90,8 +110,8 @@ export class InstallationService { throw new ApiError({ code: 'installation_already_completed', message: 'Installation has already been completed', statusCode: 409 }) } const [existingAccount] = await tx.select({ id: accounts.id }).from(accounts).limit(1) - const [existingStaffPrincipal] = await tx.select({ id: staffPrincipals.id }).from(staffPrincipals).limit(1) - if (existingAccount !== undefined || existingStaffPrincipal !== undefined) { + const [existingLegacyStaff] = await tx.select({ id: staffPrincipals.id }).from(staffPrincipals).limit(1) + if (existingAccount !== undefined || existingLegacyStaff !== undefined) { throw new ApiError({ code: 'installation_existing_data', message: 'Installation cannot run while account data already exists', @@ -100,32 +120,6 @@ export class InstallationService { } const runtimeConfiguration = initialRuntimeConfiguration(this.config, serverName) - if (input.deploymentMode === 'hosted') { - const administrator = input.administrator - const login = normalizeLocalStaffLogin(administrator.login) - if (login.length < 1 || login.length > 200) { - throw new ApiError({ code: 'installation_administrator_invalid', message: 'Administrator login is invalid', statusCode: 400 }) - } - const [principal] = await tx.insert(staffPrincipals).values({ - externalIssuer: 'https://local.notegen.invalid', - externalSubject: `local:${login}`, - displayName: administrator.login.trim(), - localLogin: login, - localPasswordHash: passwordHash, - }).returning({ id: staffPrincipals.id }) - if (principal === undefined) throw new Error('Installation Staff principal insert returned no row') - await tx.insert(staffRoleAssignments).values({ staffId: principal.id, roleKey: 'platform-admin' }) - await tx.insert(deploymentSettings).values({ - id: true, - deploymentMode: 'hosted', - registrationPolicy: input.hostedRegistrationPolicy ?? 'disabled', - selfHostedLifecycle: null, - initializedAt: new Date(), - runtimeConfiguration, - }) - return - } - const administrator = input.administrator const login = administrator.login.trim() const [created] = await tx.insert(accounts).values({ @@ -159,6 +153,7 @@ export class InstallationService { initializedByAccountId: created.id, runtimeConfiguration, }) + await tx.insert(maintenanceState).values({ id: true }) await tx.insert(adminAuditLogs).values({ actorAccountId: created.id, action: 'instance.web-installation-complete', @@ -168,7 +163,7 @@ export class InstallationService { }) }) - return { deploymentMode: input.deploymentMode, serverName, activationPending: true } + return { serverName, activationPending: true } } } diff --git a/apps/server/src/invitations/service.ts b/apps/server/src/invitations/service.ts index 58f309a..7f16caa 100644 --- a/apps/server/src/invitations/service.ts +++ b/apps/server/src/invitations/service.ts @@ -56,7 +56,7 @@ export class InvitationService { to: boundEmail, template: 'invitation', locale: this.config.mailDefaultLocale, - variables: { actionUrl: `${this.config.publicBaseUrl.replace(/\/$/, '')}/#/accept-invite/${token}` }, + variables: { actionUrl: `${this.config.webPublicBaseUrl.replace(/\/$/, '')}/account/accept-invite/?token=${encodeURIComponent(token)}` }, }, invitation.expiresAt) await this.mail.outbox.enqueueInTransaction(tx, { template: 'invitation', @@ -70,7 +70,7 @@ export class InvitationService { return [invitation] }) if (created === undefined) throw new Error('Invitation transaction returned no row') - return { id: created.id, token, url: `${this.config.webPublicBaseUrl}/#/accept-invite/${token}`, expiresAt: created.expiresAt, deliveryQueued: input.send === true } + return { id: created.id, token, url: `${this.config.webPublicBaseUrl.replace(/\/$/, '')}/account/accept-invite/?token=${encodeURIComponent(token)}`, expiresAt: created.expiresAt, deliveryQueued: input.send === true } } async inspect(token: string): Promise<{ canContinue: boolean, requiresEmail: boolean, serverName: string }> { @@ -183,7 +183,7 @@ export class InvitationService { idempotencyKey, to: old.boundEmailNormalized, template: 'invitation', locale: this.config.mailDefaultLocale, - variables: { actionUrl: `${this.config.publicBaseUrl.replace(/\/$/, '')}/#/accept-invite/${token}` }, + variables: { actionUrl: `${this.config.webPublicBaseUrl.replace(/\/$/, '')}/account/accept-invite/?token=${encodeURIComponent(token)}` }, }, replacement.expiresAt) await this.mail.outbox.enqueueInTransaction(tx, { template: 'invitation', diff --git a/apps/server/src/maintenance/service.ts b/apps/server/src/maintenance/service.ts index 6e25477..80980d5 100644 --- a/apps/server/src/maintenance/service.ts +++ b/apps/server/src/maintenance/service.ts @@ -4,7 +4,8 @@ import type { AppConfig } from '../config.js' import type { DatabaseContext } from '../database/client.js' import { blobs, blobUploads, bootstrapSessions, deviceAuthorizations, devicePairings, - stepUpGrants, supportDiagnosticGrants, syncBootstrapSessions, webSessions, workspaces, + stepUpGrants, supportDiagnosticGrants, syncBootstrapSessions, syncDeviceCursors, + webSessions, workspaces, } from '../database/schema.js' import type { BlobStorage } from '../storage/blob-storage.js' import { BLOB_COMPLETION_LEASE_MS } from '../blobs/constants.js' @@ -28,6 +29,11 @@ export interface MaintenanceResult { changes: number versions: number operations: number + syncEvents: number + syncCommands: number + syncCheckpoints: number + syncConflicts: number + syncDeviceCursors: number tombstones: number blobs: number deletionCases: number @@ -173,7 +179,8 @@ export class MaintenanceService { and not exists (select 1 from workspaces w where w.account_id = a.id) and not exists (select 1 from account_deletion_cases c where c.account_id = a.id) returning a.id` - const [changesResult, versionsResult, operationsResult, tombstonesResult, blobsResult] = + const [changesResult, versionsResult, operationsResult, syncEventsResult, syncCommandsResult, + syncCheckpointsResult, syncConflictsResult, tombstonesResult, blobsResult] = await this.database.sql.begin(async (sql) => { const removedChanges = await sql` delete from changes where id in ( @@ -219,6 +226,42 @@ export class MaintenanceService { select workspace_id, operation_id from operations where created_at < ${changeCutoff.toISOString()}::timestamptz limit 10000 ) returning operation_id` + const removedSyncEvents = await sql` + delete from sync_events where id in ( + select id from sync_events + where created_at < ${changeCutoff.toISOString()}::timestamptz + order by id limit 10000 + ) returning id` + const removedSyncCommands = await sql` + delete from sync_commands where (workspace_id, command_id) in ( + select workspace_id, command_id from sync_commands + where created_at < ${changeCutoff.toISOString()}::timestamptz + order by created_at limit 10000 + ) returning command_id` + const removedSyncCheckpoints = await sql` + delete from sync_checkpoints checkpoint where (workspace_id, checkpoint_id) in ( + select candidate.workspace_id, candidate.checkpoint_id + from sync_checkpoints candidate + left join sync_documents document + on document.workspace_id = candidate.workspace_id + and document.checkpoint_id = candidate.checkpoint_id + where candidate.created_at < ${versionCutoff.toISOString()}::timestamptz + and document.checkpoint_id is null + and not exists ( + select 1 from sync_bootstrap_objects snapshot + join sync_bootstrap_sessions session on session.id = snapshot.session_id + where snapshot.workspace_id = candidate.workspace_id + and snapshot.checkpoint_id = candidate.checkpoint_id + and session.expires_at > now() + ) + order by candidate.created_at limit 5000 + ) returning checkpoint_id` + const removedSyncConflicts = await sql` + delete from sync_conflicts where (workspace_id, conflict_id) in ( + select workspace_id, conflict_id from sync_conflicts + where status = 'resolved' and resolved_at < ${changeCutoff.toISOString()}::timestamptz + order by resolved_at limit 5000 + ) returning conflict_id` const removedTombstones = await sql` delete from objects where (workspace_id, object_id) in ( select workspace_id, object_id from objects @@ -238,9 +281,15 @@ export class MaintenanceService { where v.workspace_id = b.workspace_id and v.blob_refs ? b.blob_id ) limit 1000` - return [removedChanges, removedVersions, removedOperations, removedTombstones, candidates] as const + return [removedChanges, removedVersions, removedOperations, removedSyncEvents, + removedSyncCommands, removedSyncCheckpoints, removedSyncConflicts, + removedTombstones, candidates] as const }) + const removedSyncDeviceCursors = await this.database.db.delete(syncDeviceCursors) + .where(lt(syncDeviceCursors.updatedAt, changeCutoff)) + .returning({ deviceId: syncDeviceCursors.deviceId }) + let removedBlobs = 0 for (const blob of blobsResult) { await this.storage.delete(blob.storage_key) @@ -265,6 +314,11 @@ export class MaintenanceService { changes: changesResult.length, versions: versionsResult.length, operations: operationsResult.length, + syncEvents: syncEventsResult.length, + syncCommands: syncCommandsResult.length, + syncCheckpoints: syncCheckpointsResult.length, + syncConflicts: syncConflictsResult.length, + syncDeviceCursors: removedSyncDeviceCursors.length, tombstones: tombstonesResult.length, blobs: removedBlobs, deletionCases: advancedDeletionCases + purgingDeletionCases + finalizedDeletionAccounts, @@ -667,6 +721,11 @@ function emptyMaintenanceResult(): MaintenanceResult { changes: 0, versions: 0, operations: 0, + syncEvents: 0, + syncCommands: 0, + syncCheckpoints: 0, + syncConflicts: 0, + syncDeviceCursors: 0, tombstones: 0, blobs: 0, deletionCases: 0, diff --git a/apps/server/src/observability/metrics.ts b/apps/server/src/observability/metrics.ts index 6b9e87f..b95e288 100644 --- a/apps/server/src/observability/metrics.ts +++ b/apps/server/src/observability/metrics.ts @@ -1,10 +1,11 @@ import { timingSafeEqual } from 'node:crypto' import type { FastifyInstance } from 'fastify' -import { collectDefaultMetrics, Counter, Histogram, Registry } from 'prom-client' +import { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from 'prom-client' import type { AppConfig } from '../config.js' +import type { DatabaseContext } from '../database/client.js' import { ApiError } from '../errors.js' -export function registerMetrics(app: FastifyInstance, config: AppConfig): void { +export function registerMetrics(app: FastifyInstance, config: AppConfig, database?: DatabaseContext): void { const registry = new Registry() collectDefaultMetrics({ register: registry, prefix: 'notegen_sync_' }) const requestDuration = new Histogram({ @@ -32,6 +33,22 @@ export function registerMetrics(app: FastifyInstance, config: AppConfig): void { registers: [registry], buckets: [1_024, 64 * 1_024, 1024 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024], }) + const durableRows = new Gauge({ + name: 'notegen_sync_durable_table_rows', + help: 'Estimated live rows in durable synchronization tables', + labelNames: ['table'] as const, + registers: [registry], + }) + const activeBootstrapSnapshots = new Gauge({ + name: 'notegen_sync_active_bootstrap_snapshots', + help: 'Number of non-expired durable bootstrap snapshots', + registers: [registry], + }) + const oldestEventAge = new Gauge({ + name: 'notegen_sync_oldest_event_age_seconds', + help: 'Age of the oldest retained durable sync event', + registers: [registry], + }) app.addHook('onRequest', async (request) => { request.metricsStartedAt = process.hrtime.bigint() @@ -71,6 +88,24 @@ export function registerMetrics(app: FastifyInstance, config: AppConfig): void { return reply.status(401).send() } } + if (database !== undefined) { + try { + const [snapshot] = await database.sql>`select + (select count(*)::int from sync_bootstrap_sessions where expires_at > now()) as active_bootstraps, + coalesce((select extract(epoch from now() - min(created_at))::float8 from sync_events), 0) as oldest_event_age_seconds` + activeBootstrapSnapshots.set(snapshot?.active_bootstraps ?? 0) + oldestEventAge.set(snapshot?.oldest_event_age_seconds ?? 0) + const estimates = await database.sql>` + select relname, n_live_tup::float8 as rows from pg_stat_user_tables + where relname = any(array['sync_events','sync_commands','sync_checkpoints','sync_conflicts','sync_updates'])` + for (const estimate of estimates) durableRows.set({ table: estimate.relname }, estimate.rows) + } catch (error) { + request.log.warn({ err: error }, 'Failed to refresh durable sync metrics') + } + } return reply.type(registry.contentType).send(await registry.metrics()) }) } diff --git a/apps/server/src/routes/api-schemas.ts b/apps/server/src/routes/api-schemas.ts index 2bf195f..853eb01 100644 --- a/apps/server/src/routes/api-schemas.ts +++ b/apps/server/src/routes/api-schemas.ts @@ -15,10 +15,10 @@ export const SyncObjectKindSchema = Type.Union([ Type.Literal('folder'), Type.Literal('asset'), Type.Literal('canvas'), - Type.Literal('record'), Type.Literal('tag'), Type.Literal('mark'), Type.Literal('conversation'), + Type.Literal('message'), Type.Literal('memory'), Type.Literal('setting'), Type.Literal('yjs-checkpoint'), diff --git a/apps/server/src/routes/blobs.ts b/apps/server/src/routes/blobs.ts index 95952aa..5973bed 100644 --- a/apps/server/src/routes/blobs.ts +++ b/apps/server/src/routes/blobs.ts @@ -53,7 +53,7 @@ export function createBlobRoutes( blobId: Type.String({ pattern: '^[A-Za-z0-9_-]{43}$' }), expectedSize: Type.String({ pattern: '^\\d{1,19}$' }), ciphertextHash: Type.String({ pattern: '^[A-Za-z0-9_-]{43}$' }), - expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })), + expectedSyncEpoch: Type.String({ format: 'uuid' }), }), response: { 200: UploadSessionResponse, 201: UploadSessionResponse }, }, @@ -117,13 +117,7 @@ export function createBlobRoutes( app.post('/v1/workspaces/:workspaceId/blobs/uploads/:uploadId/complete', { schema: { params: UploadParams, - // Older clients completed an upload without a request body when the workspace did - // not expose a sync epoch. Fastify normalizes that missing body to null, so retain - // compatibility while newer clients consistently send an empty JSON object. - body: Type.Union([ - Type.Object({ expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })) }), - Type.Null(), - ]), + body: Type.Object({ expectedSyncEpoch: Type.String({ format: 'uuid' }) }), response: { 200: CompletedBlobResponse }, }, }, async (request) => { @@ -132,7 +126,7 @@ export function createBlobRoutes( claims.accountId, request.params.workspaceId, request.params.uploadId, - request.body?.expectedSyncEpoch, + request.body.expectedSyncEpoch, ) }) diff --git a/apps/server/src/routes/capabilities.ts b/apps/server/src/routes/capabilities.ts index 7db4789..f56d213 100644 --- a/apps/server/src/routes/capabilities.ts +++ b/apps/server/src/routes/capabilities.ts @@ -61,7 +61,7 @@ const CapabilitiesResponse = Type.Object({ emailVerificationRequired: Type.Boolean(), }), instanceCapabilities: Type.Record(Type.String(), Type.Boolean()), - deploymentMode: Type.Union([Type.Literal('self-hosted'), Type.Literal('hosted')]), + deploymentMode: Type.Literal('self-hosted'), web: Type.Object({ accountUrl: Type.String({ format: 'uri' }), deviceAuthorizationUrl: Type.String({ format: 'uri' }), @@ -85,9 +85,7 @@ export function createCapabilitiesRoutes( : registrationPolicy === 'invitation' ? ['invitation', 'browser'] : registrationPolicy === 'public' - ? config.deploymentMode === 'hosted' - ? instanceCapabilities['identity.emailVerification'] === true ? ['email-password'] : [] - : ['password'] + ? ['password'] : [] return { service: 'note-gen-server' as const, @@ -136,11 +134,11 @@ export function createCapabilitiesRoutes( tombstoneRetentionDays: config.tombstoneRetentionDays, }, registrationMode: dependencies.deployment?.legacyRegistrationMode() ?? config.registrationMode, - deploymentMode: dependencies.deployment?.getState().deploymentMode ?? config.deploymentMode, + deploymentMode: 'self-hosted' as const, capabilitySchema: 2 as const, instanceCapabilityRevision: dependencies.deployment?.getState().configurationRevision ?? '0', registrationPolicyRevision: dependencies.deployment?.getState().configurationRevision ?? '0', - requiredSyncFeatures: [], + requiredSyncFeatures: ['syncEpochFencing', 'durableCursorAcknowledgement'], registration: { policy: registrationPolicy, methods: registrationMethods, diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index 94ad118..63d3ffd 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -4,14 +4,12 @@ import type { TokenService } from '../auth/tokens.js' import type { AuthService } from '../auth/service.js' import type { ChangeNotifier } from '../sync/types.js' import type { WorkspaceService } from '../workspaces/service.js' -import type { MaintenanceCoordinator } from '../maintenance/coordinator.js' -import { ApiError } from '../errors.js' interface AuthenticateMessage { type: 'authenticate' accessToken: string workspaceIds: string[] - expectedSyncEpoch?: string + expectedSyncEpoch: string } interface PresenceUpdateMessage { @@ -21,24 +19,15 @@ interface PresenceUpdateMessage { anchor: number head: number label: string + canvas: { + nodes: Array<{ id: string; x: number; y: number }> + } | null } interface PresenceClearMessage { type: 'presence.clear' } -interface DocumentUpdateMessage { - type: 'document.update' - workspaceId: string - documentId: string - objectId: string - kind: string - updateId: string - keyVersion: number - ciphertext: string - ciphertextHash: string -} - interface DocumentSubscriptionMessage { type: 'document.subscribe' | 'document.unsubscribe' workspaceId: string @@ -46,7 +35,8 @@ interface DocumentSubscriptionMessage { } type RealtimeMessage = PresenceUpdateMessage | PresenceClearMessage - | DocumentUpdateMessage | DocumentSubscriptionMessage + | DocumentSubscriptionMessage +type PresenceMessage = PresenceUpdateMessage | PresenceClearMessage interface PresenceState extends PresenceUpdateMessage { deviceId: string @@ -58,14 +48,23 @@ interface PresenceConnection { workspaceIds: Set documentIds: Set presence: PresenceState | null + accountId: string + credentialEpoch: string + instanceAuthEpoch?: string + issuedAt?: number + rateTokens: number + rateUpdatedAt: number } +const REALTIME_BURST_BYTES = 4 * 1024 * 1024 +const REALTIME_BYTES_PER_SECOND = 1024 * 1024 +const MAX_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024 + export function createEventRoutes( tokens: TokenService, auth: AuthService, workspaces: WorkspaceService, notifier: ChangeNotifier, - maintenance?: MaintenanceCoordinator, syncEpoch?: string, ): FastifyPluginAsyncTypebox { const presenceRooms = new Map>() @@ -114,26 +113,23 @@ export function createEventRoutes( if (authenticated) { if (!presenceConnection) return try { + consumeRealtimeBudget(presenceConnection, Buffer.byteLength(raw.toString())) const message = parseRealtimeMessage(raw) - if (message.type === 'document.update') { - await assertRealtimeMutationAllowed(maintenance, currentSocket) - relayDocumentUpdate(message, presenceConnection, presenceRooms) - } else if (message.type === 'document.subscribe' + if (message.type === 'document.subscribe' || message.type === 'document.unsubscribe') { handleDocumentSubscription(message, presenceConnection, presenceRooms) } else { handlePresenceMessage(message, presenceConnection, presenceRooms) } - } catch (error) { - if (error instanceof ApiError && error.code === 'server_maintenance') return + } catch { currentSocket.close(1008, 'Invalid realtime message') } return } try { const message = parseAuthenticateMessage(raw) - if (message.expectedSyncEpoch !== undefined && syncEpoch !== undefined && message.expectedSyncEpoch !== syncEpoch) { - currentSocket.send(JSON.stringify({ type: 'sync.epoch-changed', code: 'sync_epoch_changed', retryable: false })) + if (syncEpoch !== undefined && message.expectedSyncEpoch !== syncEpoch) { + sendWithBackpressure(currentSocket, JSON.stringify({ type: 'sync.epoch-changed', code: 'sync_epoch_changed', retryable: false })) currentSocket.close(1008, 'Sync epoch changed') return } @@ -141,15 +137,27 @@ export function createEventRoutes( await auth.assertDeviceActive(claims.accountId, claims.deviceId, claims.credentialEpoch, claims.instanceAuthEpoch, claims.issuedAt) const uniqueWorkspaceIds = [...new Set(message.workspaceIds)] if (uniqueWorkspaceIds.length > 100) throw new Error('Too many workspace subscriptions') - await Promise.all(uniqueWorkspaceIds.map((id) => workspaces.assertOwned(claims.accountId, id))) + await Promise.all(uniqueWorkspaceIds.map((id) => ( + workspaces.assertCapability(claims.accountId, id, 'content.read') + ))) unsubscribe.push(notifier.subscribeAccount(claims.accountId, () => { if (currentSocket.readyState === currentSocket.OPEN) { - currentSocket.send(JSON.stringify({ type: 'account.workspaces-changed' })) + sendWithBackpressure(currentSocket, JSON.stringify({ type: 'account.workspaces-changed' })) } })) for (const workspaceId of uniqueWorkspaceIds) { unsubscribe.push(notifier.subscribeWorkspace(workspaceId, (notice) => { - if (currentSocket.readyState === currentSocket.OPEN) currentSocket.send(JSON.stringify(notice)) + if (currentSocket.readyState === currentSocket.OPEN) sendWithBackpressure(currentSocket, JSON.stringify(notice)) + if (notice.type === 'workspace.members-changed') { + void workspaces.assertCapability(claims.accountId, workspaceId, 'content.read').catch(() => { + if (currentSocket.readyState === currentSocket.OPEN) { + sendWithBackpressure(currentSocket, JSON.stringify({ + type: 'workspace.access-revoked', workspaceId, + })) + currentSocket.close(1008, 'Workspace access revoked') + } + }) + } })) } authenticated = true @@ -159,6 +167,12 @@ export function createEventRoutes( workspaceIds: new Set(uniqueWorkspaceIds), documentIds: new Set(), presence: null, + accountId: claims.accountId, + credentialEpoch: claims.credentialEpoch, + instanceAuthEpoch: claims.instanceAuthEpoch, + issuedAt: claims.issuedAt, + rateTokens: REALTIME_BURST_BYTES, + rateUpdatedAt: Date.now(), } for (const workspaceId of uniqueWorkspaceIds) { const room = presenceRooms.get(workspaceId) ?? new Set() @@ -169,7 +183,7 @@ export function createEventRoutes( sessionTimeout = setTimeout(() => { currentSocket.close(1008, 'Access token expired') }, Math.max(1, claims.expiresAt * 1_000 - Date.now())) - currentSocket.send(JSON.stringify({ + sendWithBackpressure(currentSocket, JSON.stringify({ type: 'authenticated', workspaceIds: uniqueWorkspaceIds, accessTokenExpiresAt: new Date(claims.expiresAt * 1_000).toISOString(), @@ -182,21 +196,6 @@ export function createEventRoutes( } } -async function assertRealtimeMutationAllowed(maintenance: MaintenanceCoordinator | undefined, socket: WebSocket): Promise { - if (maintenance === undefined) return - try { - await maintenance.requireMutationAllowed('/v1/sync/events') - } catch (error) { - if (error instanceof ApiError && error.code === 'server_maintenance') { - if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ - type: 'server.maintenance', code: error.code, retryable: error.retryable, details: error.details, - })) - socket.close(1013, 'Server maintenance') - } - throw error - } -} - function parseAuthenticateMessage(raw: RawData): AuthenticateMessage { const value: unknown = JSON.parse(raw.toString()) if (typeof value !== 'object' || value === null) throw new Error('Message must be an object') @@ -204,16 +203,15 @@ function parseAuthenticateMessage(raw: RawData): AuthenticateMessage { if (candidate.type !== 'authenticate' || typeof candidate.accessToken !== 'string' || !Array.isArray(candidate.workspaceIds) || !candidate.workspaceIds.every((id) => typeof id === 'string') - || (candidate.expectedSyncEpoch !== undefined - && (typeof candidate.expectedSyncEpoch !== 'string' - || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.expectedSyncEpoch)))) { + || typeof candidate.expectedSyncEpoch !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.expectedSyncEpoch)) { throw new Error('Authentication message is invalid') } return { type: 'authenticate', accessToken: candidate.accessToken, workspaceIds: candidate.workspaceIds, - ...(candidate.expectedSyncEpoch === undefined ? {} : { expectedSyncEpoch: candidate.expectedSyncEpoch as string }), + expectedSyncEpoch: candidate.expectedSyncEpoch, } } @@ -232,31 +230,6 @@ function parseRealtimeMessage(raw: RawData): RealtimeMessage { documentId: candidate.documentId.slice(0, 200), } } - if (candidate.type === 'document.update') { - if (typeof candidate.workspaceId !== 'string' - || typeof candidate.documentId !== 'string' - || typeof candidate.objectId !== 'string' - || typeof candidate.kind !== 'string' - || typeof candidate.updateId !== 'string' - || typeof candidate.keyVersion !== 'number' || !Number.isSafeInteger(candidate.keyVersion) - || candidate.keyVersion < 1 - || typeof candidate.ciphertext !== 'string' - || typeof candidate.ciphertextHash !== 'string' - || candidate.ciphertext.length > 4 * 1024 * 1024) { - throw new Error('Document update message is invalid') - } - return { - type: 'document.update', - workspaceId: candidate.workspaceId, - documentId: candidate.documentId.slice(0, 200), - objectId: candidate.objectId.slice(0, 200), - kind: candidate.kind.slice(0, 40), - updateId: candidate.updateId.slice(0, 200), - keyVersion: candidate.keyVersion, - ciphertext: candidate.ciphertext, - ciphertextHash: candidate.ciphertextHash.slice(0, 200), - } - } if (candidate.type !== 'presence.update' || typeof candidate.workspaceId !== 'string' || typeof candidate.documentId !== 'string' @@ -273,6 +246,26 @@ function parseRealtimeMessage(raw: RawData): RealtimeMessage { anchor: candidate.anchor, head: candidate.head, label: candidate.label.trim().slice(0, 40) || '其他设备', + canvas: parseCanvasPresence(candidate.canvas), + } +} + +function parseCanvasPresence(value: unknown): PresenceUpdateMessage['canvas'] { + if (value === undefined || value === null) return null + if (typeof value !== 'object' || value === null) throw new Error('Canvas presence is invalid') + const nodes = (value as Record).nodes + if (!Array.isArray(nodes) || nodes.length > 100) throw new Error('Canvas presence is invalid') + return { + nodes: nodes.map((node) => { + if (typeof node !== 'object' || node === null) throw new Error('Canvas presence node is invalid') + const candidate = node as Record + if (typeof candidate.id !== 'string' || candidate.id.length === 0 || candidate.id.length > 200 + || typeof candidate.x !== 'number' || !Number.isFinite(candidate.x) + || typeof candidate.y !== 'number' || !Number.isFinite(candidate.y)) { + throw new Error('Canvas presence node is invalid') + } + return { id: candidate.id, x: candidate.x, y: candidate.y } + }), } } @@ -298,31 +291,28 @@ function handleDocumentSubscription( for (const peer of rooms.get(message.workspaceId) ?? []) { if (peer !== connection && peer.documentIds.has(key) && peer.socket.readyState === peer.socket.OPEN) { - peer.socket.send(payload) + sendWithBackpressure(peer.socket, payload) } } } -function relayDocumentUpdate( - message: DocumentUpdateMessage, - connection: PresenceConnection, - rooms: Map>, -): void { - if (!connection.workspaceIds.has(message.workspaceId)) throw new Error('Workspace is not subscribed') - const room = rooms.get(message.workspaceId) - if (!room) return - const payload = JSON.stringify({ - ...message, - type: 'document.updated.realtime', - deviceId: connection.deviceId, - }) - for (const peer of room) { - if (peer !== connection - && peer.documentIds.has(`${message.workspaceId}\0${message.documentId}`) - && peer.socket.readyState === peer.socket.OPEN) { - peer.socket.send(payload) - } +function consumeRealtimeBudget(connection: PresenceConnection, bytes: number): void { + const now = Date.now() + const replenished = Math.min( + REALTIME_BURST_BYTES, + connection.rateTokens + ((now - connection.rateUpdatedAt) / 1_000) * REALTIME_BYTES_PER_SECOND, + ) + connection.rateUpdatedAt = now + connection.rateTokens = replenished - bytes + if (connection.rateTokens < 0) throw new Error('Realtime rate limit exceeded') +} + +function sendWithBackpressure(socket: WebSocket, payload: string): void { + if (socket.bufferedAmount > MAX_SOCKET_BUFFERED_BYTES) { + socket.close(1013, 'Realtime consumer is too slow') + return } + socket.send(payload) } function handlePresenceMessage( @@ -375,13 +365,13 @@ function clearPresence( deviceId: connection.deviceId, }) for (const peer of room) { - if (peer !== connection && peer.socket.readyState === peer.socket.OPEN) peer.socket.send(payload) + if (peer !== connection && peer.socket.readyState === peer.socket.OPEN) sendWithBackpressure(peer.socket, payload) } } function sendPresence(presence: PresenceState, socket: WebSocket): void { if (socket.readyState !== socket.OPEN) return - socket.send(JSON.stringify({ + sendWithBackpressure(socket, JSON.stringify({ type: 'presence.updated', workspaceId: presence.workspaceId, documentId: presence.documentId, @@ -389,5 +379,6 @@ function sendPresence(presence: PresenceState, socket: WebSocket): void { label: presence.label, anchor: presence.anchor, head: presence.head, + canvas: presence.canvas, })) } diff --git a/apps/server/src/routes/installation.ts b/apps/server/src/routes/installation.ts index 7828d7e..ab57286 100644 --- a/apps/server/src/routes/installation.ts +++ b/apps/server/src/routes/installation.ts @@ -13,7 +13,6 @@ export function createInstallationRoutes( const Status = Type.Object({ installationRequired: Type.Boolean(), activationPending: Type.Boolean(), - deploymentMode: Type.Union([Type.Literal('self-hosted'), Type.Literal('hosted'), Type.Null()]), serverName: Type.String(), }) @@ -23,9 +22,7 @@ export function createInstallationRoutes( config: { rateLimit: { max: 5, timeWindow: '1 minute' } }, schema: { body: Type.Object({ - deploymentMode: Type.Union([Type.Literal('self-hosted'), Type.Literal('hosted')]), serverName: Type.String({ minLength: 1, maxLength: 100, pattern: '.*\\S.*' }), - hostedRegistrationPolicy: Type.Optional(Type.Union([Type.Literal('disabled'), Type.Literal('public')])), administrator: Type.Object({ login: Type.String({ minLength: 1, maxLength: 200, pattern: '.*\\S.*' }), password: Type.String({ minLength: 8, maxLength: 1024 }), @@ -33,7 +30,6 @@ export function createInstallationRoutes( }), response: { 201: Type.Object({ - deploymentMode: Type.Union([Type.Literal('self-hosted'), Type.Literal('hosted')]), serverName: Type.String(), activationPending: Type.Literal(true), }), diff --git a/apps/server/src/routes/sync.ts b/apps/server/src/routes/sync.ts index 40853fa..a6b8b7e 100644 --- a/apps/server/src/routes/sync.ts +++ b/apps/server/src/routes/sync.ts @@ -5,6 +5,7 @@ import { requireAuth } from '../auth/http-auth.js' import type { TokenService } from '../auth/tokens.js' import type { DurableSyncService } from '../durable-sync/service.js' import { syncObjectKinds } from '../sync/types.js' +import { workspaceCapabilities } from '../workspaces/capabilities.js' const Counter = Type.String({ pattern: '^\\d{1,19}$' }) const Hash = Type.String({ pattern: '^[A-Za-z0-9_-]{43}$' }) @@ -15,17 +16,88 @@ const ObjectVersionParams = Type.Object({ revision: Counter, }) const Kind = Type.Union(syncObjectKinds.map(kind => Type.Literal(kind))) +const Capability = Type.Union(workspaceCapabilities.map(capability => Type.Literal(capability))) const Envelope = { keyVersion: Type.Integer({ minimum: 1 }), ciphertext: Type.String({ pattern: '^[A-Za-z0-9_-]+$' }), ciphertextHash: Hash, } +const OptionalNameCiphertext = Type.Optional(Type.Union([ + Type.String({ maxLength: 16 * 1024 }), Type.Null(), +])) const CommandId = { commandId: Type.String({ format: 'uuid' }) } +const NullableUuid = Type.Union([Type.String({ format: 'uuid' }), Type.Null()]) +const NullableCounter = Type.Union([Counter, Type.Null()]) +const NullableString = Type.Union([Type.String(), Type.Null()]) +const SyncEpochField = { syncEpoch: Type.String({ format: 'uuid' }) } +const CommandResult = Type.Object({ + commandId: Type.String({ format: 'uuid' }), + status: Type.Union([Type.Literal('applied'), Type.Literal('conflict'), Type.Literal('rejected')]), + duplicate: Type.Boolean(), + sequence: Type.Optional(Counter), + revision: Type.Optional(Counter), + documentSequence: Type.Optional(Counter), + conflictId: Type.Optional(Type.String({ format: 'uuid' })), + code: Type.Optional(Type.String()), + retryable: Type.Optional(Type.Boolean()), + details: Type.Optional(Type.Record(Type.String(), Type.Unknown())), +}) +const SyncEvent = Type.Object({ + eventId: Type.String({ format: 'uuid' }), + commandId: Type.String({ format: 'uuid' }), + sourceDeviceId: Type.String({ format: 'uuid' }), + type: Type.String(), + objectId: NullableUuid, + documentId: NullableString, + sequence: Counter, + documentSequence: NullableCounter, + keyVersion: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()]), + ciphertext: NullableString, + ciphertextHash: Type.Union([Hash, Type.Null()]), + metadata: Type.Record(Type.String(), Type.Unknown()), + createdAt: Type.String({ format: 'date-time' }), +}) +const ObjectVersion = Type.Object({ + workspaceId: Type.String({ format: 'uuid' }), + objectId: Type.String({ format: 'uuid' }), + revision: Counter, + sequence: Counter, + kind: Kind, + parentObjectId: NullableUuid, + nameCiphertext: NullableString, + nameBlindIndex: NullableString, + ciphertext: Type.String(), + ciphertextHash: Hash, + keyVersion: Type.Integer({ minimum: 1 }), + blobRefs: Type.Array(Hash), + sourceDeviceId: Type.String({ format: 'uuid' }), + deleted: Type.Boolean(), + createdAt: Type.String({ format: 'date-time' }), +}) +const Conflict = Type.Object({ + workspaceId: Type.String({ format: 'uuid' }), + conflictId: Type.String({ format: 'uuid' }), + objectId: Type.String({ format: 'uuid' }), + kind: Kind, + type: Type.String(), + status: Type.String(), + expectedRevision: NullableCounter, + expectedDocumentSequence: NullableCounter, + keyVersion: Type.Integer({ minimum: 1 }), + ciphertext: Type.String(), + ciphertextHash: Hash, + createdSequence: Counter, + resolvedSequence: NullableCounter, + resolvedByDeviceId: NullableUuid, + resolvedAt: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]), + createdAt: Type.String({ format: 'date-time' }), + updatedAt: Type.String({ format: 'date-time' }), +}) const Command = Type.Union([ Type.Object({ ...CommandId, type: Type.Literal('upsert-object'), objectId: Type.String({ format: 'uuid' }), kind: Kind, parentObjectId: Type.Optional(Type.Union([Type.String({ format: 'uuid' }), Type.Null()])), - nameCiphertext: Type.Optional(Type.Union([Type.String(), Type.Null()])), + nameCiphertext: OptionalNameCiphertext, nameBlindIndex: Type.Optional(Type.Union([Hash, Type.Null()])), nameBlindIndexKeyVersion: Type.Optional(Type.Integer({ minimum: 1 })), nameConflictId: Type.Optional(Type.String({ format: 'uuid' })), @@ -38,7 +110,7 @@ const Command = Type.Union([ Type.Object({ ...CommandId, type: Type.Literal('delete-object'), objectId: Type.String({ format: 'uuid' }), kind: Kind, parentObjectId: Type.Optional(Type.Union([Type.String({ format: 'uuid' }), Type.Null()])), - nameCiphertext: Type.Optional(Type.Union([Type.String(), Type.Null()])), + nameCiphertext: OptionalNameCiphertext, baseRevision: Counter, expectedDocumentSequence: Counter, blobRefs: Type.Array(Hash, { maxItems: 1_000 }), conflictId: Type.String({ format: 'uuid' }), conflictCiphertext: Type.String({ pattern: '^[A-Za-z0-9_-]+$' }), conflictCiphertextHash: Hash, ...Envelope, @@ -47,11 +119,11 @@ const Command = Type.Union([ ...CommandId, type: Type.Literal('delete-subtree'), rootObjectId: Type.String({ format: 'uuid' }), conflictId: Type.String({ format: 'uuid' }), conflictKeyVersion: Type.Integer({ minimum: 1 }), conflictCiphertext: Type.String({ pattern: '^[A-Za-z0-9_-]+$' }), conflictCiphertextHash: Hash, - mutationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }), { maxItems: 10_000 })), + mutationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }), { maxItems: 2_000 })), objects: Type.Array(Type.Object({ objectId: Type.String({ format: 'uuid' }), kind: Kind, baseRevision: Counter, expectedDocumentSequence: Counter, blobRefs: Type.Array(Hash, { maxItems: 1_000 }), ...Envelope, - }), { minItems: 1, maxItems: 10_000 }), + }), { minItems: 1, maxItems: 2_000 }), }), Type.Object({ ...CommandId, type: Type.Literal('append-update'), updateId: Type.String({ format: 'uuid' }), @@ -77,7 +149,7 @@ const Command = Type.Union([ objectResolution: Type.Optional(Type.Object({ objectId: Type.String({ format: 'uuid' }), kind: Kind, parentObjectId: Type.Optional(Type.Union([Type.String({ format: 'uuid' }), Type.Null()])), - nameCiphertext: Type.Optional(Type.Union([Type.String(), Type.Null()])), + nameCiphertext: OptionalNameCiphertext, nameBlindIndex: Type.Optional(Type.Union([Hash, Type.Null()])), nameBlindIndexKeyVersion: Type.Optional(Type.Integer({ minimum: 1 })), blobRefs: Type.Optional(Type.Array(Hash, { maxItems: 1_000 })), ...Envelope, @@ -96,14 +168,83 @@ export function createSyncRoutes( auth: AuthService, ): FastifyPluginAsyncTypebox { return async function syncRoutes(app) { + app.get('/v1/workspaces/:workspaceId/sync/session', { + schema: { + params: WorkspaceParams, + querystring: Type.Object({ + protocolVersion: Type.Integer({ minimum: 1 }), + cursor: Type.Optional(Counter), + expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })), + }), + response: { + 200: Type.Object({ + protocol: Type.Object({ + requestedVersion: Type.Integer(), + selectedVersion: Type.Literal(1), + compatible: Type.Boolean(), + }), + workspace: Type.Object({ + id: Type.String({ format: 'uuid' }), + type: Type.Union([Type.Literal('account-data'), Type.Literal('library')]), + role: Type.Union([ + Type.Literal('owner'), Type.Literal('viewer'), Type.Literal('editor'), Type.Literal('manager'), + ]), + owner: Type.Boolean(), + capabilities: Type.Array(Capability), + }), + cursor: Type.Object({ + supplied: Counter, + state: Type.Union([Type.Literal('valid'), Type.Literal('ahead'), Type.Literal('expired')]), + acknowledged: Counter, + oldestAvailableSequence: Type.Union([Counter, Type.Null()]), + }), + latestSequence: Counter, + bootstrap: Type.Object({ + required: Type.Boolean(), + reason: Type.Union([Type.Literal('cursor_ahead'), Type.Literal('cursor_expired'), Type.Null()]), + }), + limits: Type.Object({ + maxCommandsPerBatch: Type.Integer(), + maxEventsPerPage: Type.Integer(), + maxBootstrapObjectsPerPage: Type.Integer(), + maxDocumentUpdatesPerPage: Type.Integer(), + maxObjectBytes: Type.Integer(), + }), + keyVersions: Type.Array(Type.Object({ + keyVersion: Type.Integer({ minimum: 1 }), + createdAt: Type.String({ format: 'date-time' }), + })), + syncEpoch: Type.String({ format: 'uuid' }), + websocketUrl: Type.String({ format: 'uri' }), + }), + }, + }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + const session = await sync.session( + claims.accountId, + claims.deviceId, + request.params.workspaceId, + request.query.cursor ?? '0', + request.query.protocolVersion, + request.query.expectedSyncEpoch, + ) + const websocketScheme = request.protocol === 'https' ? 'wss' : 'ws' + return { + ...session, + websocketUrl: `${websocketScheme}://${request.host}/v1/sync/events`, + } + }) + app.post('/v1/workspaces/:workspaceId/sync/commands', { config: { rateLimit: { max: 600, timeWindow: '1 minute' } }, schema: { params: WorkspaceParams, body: Type.Object({ commands: Type.Array(Command, { minItems: 1, maxItems: 100 }), - expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })), + expectedSyncEpoch: Type.String({ format: 'uuid' }), }), + response: { 200: Type.Object({ results: Type.Array(CommandResult), ...SyncEpochField }) }, }, }, async request => { const claims = await requireAuth(request, tokens, auth) @@ -118,10 +259,31 @@ export function createSyncRoutes( return result }) + app.post('/v1/workspaces/:workspaceId/sync/ack', { + schema: { + params: WorkspaceParams, + body: Type.Object({ + through: Counter, + expectedSyncEpoch: Type.String({ format: 'uuid' }), + }), + response: { 200: Type.Object({ acknowledgedSequence: Counter, ...SyncEpochField }) }, + }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return sync.acknowledge( + claims.accountId, claims.deviceId, request.params.workspaceId, + request.body.through, request.body.expectedSyncEpoch, + ) + }) + app.get('/v1/workspaces/:workspaceId/sync/events', { schema: { params: WorkspaceParams, - querystring: Type.Object({ after: Type.Optional(Counter), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000 })), expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })) }), + querystring: Type.Object({ after: Type.Optional(Counter), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000 })), expectedSyncEpoch: Type.String({ format: 'uuid' }) }), + response: { 200: Type.Object({ + events: Type.Array(SyncEvent), nextCursor: Counter, latestSequence: Counter, + hasMore: Type.Boolean(), ...SyncEpochField, + }) }, }, }, async request => { const claims = await requireAuth(request, tokens, auth) @@ -129,7 +291,15 @@ export function createSyncRoutes( }) app.get('/v1/workspaces/:workspaceId/sync/objects/:objectId/versions/:revision', { - schema: { params: ObjectVersionParams, querystring: Type.Object({ expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })) }) }, + schema: { + params: ObjectVersionParams, + querystring: Type.Object({ expectedSyncEpoch: Type.String({ format: 'uuid' }) }), + response: { 200: Type.Object({ + object: Type.Intersect([ObjectVersion, Type.Object({ currentRevision: NullableCounter })]), + resources: Type.Array(Type.Intersect([ObjectVersion, Type.Object({ currentRevision: NullableCounter })])), + ...SyncEpochField, + }) }, + }, }, async request => { const claims = await requireAuth(request, tokens, auth) return sync.objectVersion( @@ -149,8 +319,12 @@ export function createSyncRoutes( querystring: Type.Object({ before: Type.Optional(Counter), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), - expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })), + expectedSyncEpoch: Type.String({ format: 'uuid' }), }), + response: { 200: Type.Object({ + versions: Type.Array(ObjectVersion), nextBefore: NullableCounter, + hasMore: Type.Boolean(), ...SyncEpochField, + }) }, }, }, async request => { const claims = await requireAuth(request, tokens, auth) @@ -167,8 +341,26 @@ export function createSyncRoutes( bootstrapId: Type.Optional(Type.String({ format: 'uuid' })), afterObjectId: Type.Optional(Type.String({ format: 'uuid' })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000 })), - expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })), + expectedSyncEpoch: Type.String({ format: 'uuid' }), }), + response: { 200: Type.Object({ + bootstrapId: Type.String({ format: 'uuid' }), + snapshotSequence: Counter, + objects: Type.Array(Type.Object({ + objectId: Type.String({ format: 'uuid' }), kind: Kind, parentObjectId: NullableUuid, + nameCiphertext: NullableString, nameBlindIndexPresent: Type.Boolean(), currentRevision: Counter, + ciphertext: Type.String(), ciphertextHash: Hash, keyVersion: Type.Integer({ minimum: 1 }), + blobRefs: Type.Array(Hash), deletedAt: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]), + document: Type.Union([Type.Object({ + documentId: Type.String(), latestDocumentSequence: Counter, checkpointDocumentSequence: Counter, + checkpointId: NullableUuid, checkpointKeyVersion: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()]), + checkpointCiphertext: NullableString, checkpointCiphertextHash: Type.Union([Hash, Type.Null()]), + materializedRevision: NullableCounter, + }), Type.Null()]), + })), + conflicts: Type.Array(Conflict), nextObjectId: NullableUuid, + hasMore: Type.Boolean(), ...SyncEpochField, + }) }, }, }, async request => { const claims = await requireAuth(request, tokens, auth) @@ -181,7 +373,16 @@ export function createSyncRoutes( app.get('/v1/workspaces/:workspaceId/documents/:documentId/updates', { schema: { params: Type.Object({ workspaceId: Type.String({ format: 'uuid' }), documentId: Type.String({ minLength: 1, maxLength: 512 }) }), - querystring: Type.Object({ after: Type.Optional(Counter), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000 })), expectedSyncEpoch: Type.Optional(Type.String({ format: 'uuid' })) }), + querystring: Type.Object({ after: Type.Optional(Counter), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000 })), expectedSyncEpoch: Type.String({ format: 'uuid' }) }), + response: { 200: Type.Object({ + updates: Type.Array(Type.Object({ + workspaceId: Type.String({ format: 'uuid' }), documentId: Type.String(), documentSequence: Counter, + updateId: Type.String({ format: 'uuid' }), eventSequence: Counter, + sourceDeviceId: Type.String({ format: 'uuid' }), keyVersion: Type.Integer({ minimum: 1 }), + ciphertext: Type.String(), ciphertextHash: Hash, createdAt: Type.String({ format: 'date-time' }), + })), + nextDocumentSequence: Counter, hasMore: Type.Boolean(), ...SyncEpochField, + }) }, }, }, async request => { const claims = await requireAuth(request, tokens, auth) diff --git a/apps/server/src/routes/web-admin.ts b/apps/server/src/routes/web-admin.ts index e12027b..398ab63 100644 --- a/apps/server/src/routes/web-admin.ts +++ b/apps/server/src/routes/web-admin.ts @@ -162,7 +162,7 @@ export function createWebAdminRoutes( app.get('/v1/web/admin/capabilities', { schema: { response: { 200: Type.Object({ - deploymentMode: Type.Union([Type.Literal('self-hosted'), Type.Literal('hosted')]), + deploymentMode: Type.Literal('self-hosted'), generatedAt: Timestamp, capabilities: Type.Array(Type.Object({ id: Type.String(), lifecycle: Type.Union([Type.Literal('stable'), Type.Literal('experimental')]), @@ -176,13 +176,12 @@ export function createWebAdminRoutes( const session = await requireWebSession(request.cookies[WEB_SESSION_COOKIE], webSessions) await admin.assertAdmin(session.accountId) const experimental = new Set([ - 'identity.email', 'identity.emailVerification', 'identity.passwordReset', 'risk.advanced', - 'usage.enforcement', 'billing.subscription', 'compliance.requests', 'support.cases', + 'identity.email', 'identity.emailVerification', 'identity.passwordReset', 'operations.unifiedBackup', 'operations.upgradeAssistant', 'operations.preserveRestore', ]) const rows = capabilityIds.map((id) => { const explanation = capabilities?.explain(id) ?? { - id, available: false, deploymentMode: _config.deploymentMode, requestedBy: 'default' as const, + id, available: false, deploymentMode: 'self-hosted' as const, requestedBy: 'default' as const, reasons: ['capability_registry_unavailable'], dependencies: [], } const status = explanation.available @@ -200,7 +199,7 @@ export function createWebAdminRoutes( } }) return { - deploymentMode: capabilities?.explain(capabilityIds[0]).deploymentMode ?? _config.deploymentMode, + deploymentMode: 'self-hosted' as const, generatedAt: new Date(), capabilities: rows, } diff --git a/apps/server/src/routes/web-auth.ts b/apps/server/src/routes/web-auth.ts index 7342243..3a27249 100644 --- a/apps/server/src/routes/web-auth.ts +++ b/apps/server/src/routes/web-auth.ts @@ -10,7 +10,7 @@ import type { WebAccountSession, WebSessionService } from '../auth/web-session-s import type { AdminService } from '../admin/service.js' import type { DeploymentService } from '../deployment/service.js' import type { RiskService } from '../risk/service.js' -import { NullableTimestamp, Timestamp } from './api-schemas.js' +import { CounterString, NullableTimestamp, Timestamp } from './api-schemas.js' export const WEB_SESSION_COOKIE = 'notegen_session' export const WEB_CSRF_COOKIE = 'notegen_csrf' @@ -134,6 +134,8 @@ export function createWebAuthRoutes( id: Type.String({ format: 'uuid' }), name: Type.String(), platform: Type.String(), encryptionPublicKey: Type.Union([Type.String(), Type.Null()]), lastSeenAt: Timestamp, createdAt: Timestamp, revokedAt: NullableTimestamp, current: Type.Boolean(), + syncStatus: Type.Union([Type.Literal('caught-up'), Type.Literal('behind'), Type.Literal('never-acknowledged')]), + pendingEventCount: CounterString, acknowledgedAt: NullableTimestamp, })) } } }, async (request) => { const session = await requireWebSession(request.cookies[WEB_SESSION_COOKIE], webSessions) return auth.listDevices(session.accountId) @@ -196,6 +198,28 @@ export function createWebAuthRoutes( return { revoked: await webSessions.destroyOthers(session.accountId, session.sessionId) } }) + app.delete('/v1/web/account', { + config: { rateLimit: { max: 3, timeWindow: '1 hour' } }, + schema: { + body: Type.Object({ + password: Type.String({ minLength: 8, maxLength: 1_024 }), + confirmation: Type.Literal('DELETE'), + }), + response: { 202: Type.Object({ purgeAfter: Timestamp }) }, + }, + }, async (request, reply) => { + const session = await requireWebSession(request.cookies[WEB_SESSION_COOKIE], webSessions) + requireCsrf(request.headers['x-csrf-token'], request.cookies[WEB_CSRF_COOKIE], session, webSessions) + const purgeAfter = await auth.requestAccountDeletion( + session.accountId, request.body.password, config.tombstoneRetentionDays, + ) + await webSessions.destroyForAccount(session.accountId) + clearSessionCookies(config, reply) + await admin?.recordAudit(session.accountId, 'account.deletion-request', 'account', session.accountId) + .catch((error: unknown) => request.log.warn({ err: error }, 'Failed to record account deletion request')) + return reply.status(202).send({ purgeAfter }) + }) + app.delete('/v1/web/devices/:deviceId', { schema: { params: Type.Object({ deviceId: Type.String({ format: 'uuid' }) }), diff --git a/apps/server/src/routes/web-dashboard.ts b/apps/server/src/routes/web-dashboard.ts index 7501964..51f447c 100644 --- a/apps/server/src/routes/web-dashboard.ts +++ b/apps/server/src/routes/web-dashboard.ts @@ -41,6 +41,13 @@ export function createWebDashboardRoutes( encryptionMode: Type.Union([ Type.Literal('managed'), Type.Literal('e2ee'), Type.Literal('mixed'), Type.Null(), ]), + storageUsage: Type.Union([Type.Object({ + activeObjectBytes: CounterString, + activeCrdtBytes: CounterString, + activeBlobBytes: CounterString, + reservedBlobBytes: CounterString, + retainedBytes: CounterString, + }), Type.Null()]), kinds: Type.Array(Type.Object({ kind: Type.String(), activeCount: Type.Integer(), @@ -96,5 +103,24 @@ export function createWebDashboardRoutes( .catch((error: unknown) => request.log.warn({ err: error }, 'Failed to record workspace deletion audit')) return reply.status(204).send(null) }) + + app.post('/v1/web/workspaces/:workspaceId/restore', { + schema: { + params: Type.Object({ workspaceId: Type.String({ format: 'uuid' }) }), + response: { 204: Type.Null() }, + }, + }, async (request, reply) => { + const session = await requireWebSession(request.cookies[WEB_SESSION_COOKIE], webSessions) + requireCsrf( + request.headers['x-csrf-token'], + request.cookies[WEB_CSRF_COOKIE], + session, + webSessions, + ) + await workspaces.restore(session.accountId, request.params.workspaceId) + await admin?.recordAudit(session.accountId, 'workspace.restore', 'workspace', request.params.workspaceId) + .catch((error: unknown) => request.log.warn({ err: error }, 'Failed to record workspace restoration audit')) + return reply.status(204).send(null) + }) } } diff --git a/apps/server/src/routes/workspace-collaboration.ts b/apps/server/src/routes/workspace-collaboration.ts new file mode 100644 index 0000000..7fd3c22 --- /dev/null +++ b/apps/server/src/routes/workspace-collaboration.ts @@ -0,0 +1,164 @@ +import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox' +import { Type } from '@sinclair/typebox' +import type { AuthService } from '../auth/service.js' +import { requireAuth } from '../auth/http-auth.js' +import type { TokenService } from '../auth/tokens.js' +import type { WorkspaceCollaborationService } from '../workspaces/collaboration-service.js' +import { workspaceCapabilities } from '../workspaces/capabilities.js' + +const WorkspaceParams = Type.Object({ workspaceId: Type.String({ format: 'uuid' }) }) +const MemberParams = Type.Object({ + workspaceId: Type.String({ format: 'uuid' }), + accountId: Type.String({ format: 'uuid' }), +}) +const InvitationParams = Type.Object({ + workspaceId: Type.String({ format: 'uuid' }), + invitationId: Type.String({ format: 'uuid' }), +}) +const Role = Type.Union([Type.Literal('viewer'), Type.Literal('editor'), Type.Literal('manager')]) +const Capability = Type.Union(workspaceCapabilities.map(capability => Type.Literal(capability))) +const Membership = Type.Object({ + role: Role, + capabilities: Type.Optional(Type.Array(Capability, { uniqueItems: true, maxItems: workspaceCapabilities.length })), +}) +const NullableTimestamp = Type.Union([Type.String({ format: 'date-time' }), Type.Null()]) +const MemberResponse = Type.Object({ + accountId: Type.String({ format: 'uuid' }), + login: Type.String(), + role: Type.Union([Type.Literal('owner'), Role]), + capabilities: Type.Array(Capability), + joinedAt: NullableTimestamp, + updatedAt: NullableTimestamp, +}) +const InvitationResponse = Type.Object({ + id: Type.String({ format: 'uuid' }), + workspaceId: Type.String({ format: 'uuid' }), + kind: Type.Union([Type.Literal('account'), Type.Literal('link')]), + inviteeAccountId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]), + tokenHint: Type.Union([Type.String(), Type.Null()]), + role: Role, + capabilities: Type.Array(Capability), + status: Type.Union([ + Type.Literal('pending'), Type.Literal('accepted'), Type.Literal('revoked'), Type.Literal('expired'), + ]), + invitedByAccountId: Type.String({ format: 'uuid' }), + acceptedByAccountId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]), + expiresAt: Type.String({ format: 'date-time' }), + acceptedAt: NullableTimestamp, + revokedAt: NullableTimestamp, + createdAt: Type.String({ format: 'date-time' }), + updatedAt: Type.String({ format: 'date-time' }), +}) + +export function createWorkspaceCollaborationRoutes( + collaboration: WorkspaceCollaborationService, + tokens: TokenService, + auth: AuthService, +): FastifyPluginAsyncTypebox { + return async function workspaceCollaborationRoutes(app) { + app.get('/v1/workspaces/:workspaceId/members', { + schema: { params: WorkspaceParams, response: { 200: Type.Array(MemberResponse) } }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return collaboration.listMembers(claims.accountId, request.params.workspaceId) + }) + + app.get('/v1/workspaces/:workspaceId/invitations', { + schema: { params: WorkspaceParams, response: { 200: Type.Array(InvitationResponse) } }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return collaboration.listInvitations(claims.accountId, request.params.workspaceId) + }) + + app.get('/v1/workspace-invitations', { + schema: { + response: { 200: Type.Array(Type.Intersect([InvitationResponse, Type.Object({ + workspaceNameCiphertext: Type.String(), + inviterLogin: Type.String(), + })])) }, + }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return collaboration.listPendingForAccount(claims.accountId) + }) + + app.post('/v1/workspaces/:workspaceId/invitations/account', { + schema: { + params: WorkspaceParams, + body: Type.Intersect([Membership, Type.Object({ + login: Type.String({ minLength: 1, maxLength: 320 }), + expiresInSeconds: Type.Optional(Type.Integer({ minimum: 300, maximum: 30 * 24 * 60 * 60 })), + })]), + response: { 201: Type.Intersect([InvitationResponse, Type.Object({ inviteeLogin: Type.String() })]) }, + }, + }, async (request, reply) => { + const claims = await requireAuth(request, tokens, auth) + const result = await collaboration.inviteAccount( + claims.accountId, request.params.workspaceId, request.body.login, + request.body, request.body.expiresInSeconds, + ) + return reply.status(201).send(result) + }) + + app.post('/v1/workspaces/:workspaceId/invitations/link', { + schema: { + params: WorkspaceParams, + body: Type.Intersect([Membership, Type.Object({ + expiresInSeconds: Type.Optional(Type.Integer({ minimum: 300, maximum: 30 * 24 * 60 * 60 })), + })]), + response: { 201: Type.Intersect([InvitationResponse, Type.Object({ token: Type.String() })]) }, + }, + }, async (request, reply) => { + const claims = await requireAuth(request, tokens, auth) + const result = await collaboration.createLink( + claims.accountId, request.params.workspaceId, request.body, request.body.expiresInSeconds, + ) + return reply.status(201).send(result) + }) + + app.post('/v1/workspace-invitations/:invitationId/accept', { + schema: { + params: Type.Object({ invitationId: Type.String({ format: 'uuid' }) }), + response: { 200: Type.Object({ workspaceId: Type.String({ format: 'uuid' }) }) }, + }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return collaboration.acceptAccountInvitation(claims.accountId, request.params.invitationId) + }) + + app.post('/v1/workspace-invitations/accept-link', { + schema: { + body: Type.Object({ token: Type.String({ minLength: 32, maxLength: 128 }) }), + response: { 200: Type.Object({ workspaceId: Type.String({ format: 'uuid' }) }) }, + }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return collaboration.acceptLink(claims.accountId, request.body.token) + }) + + app.patch('/v1/workspaces/:workspaceId/members/:accountId', { + schema: { params: MemberParams, body: Membership, response: { 200: MemberResponse } }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return collaboration.updateMember( + claims.accountId, request.params.workspaceId, request.params.accountId, request.body, + ) + }) + + app.delete('/v1/workspaces/:workspaceId/members/:accountId', { + schema: { params: MemberParams, response: { 204: Type.Null() } }, + }, async (request, reply) => { + const claims = await requireAuth(request, tokens, auth) + await collaboration.removeMember(claims.accountId, request.params.workspaceId, request.params.accountId) + return reply.status(204).send(null) + }) + + app.delete('/v1/workspaces/:workspaceId/invitations/:invitationId', { + schema: { params: InvitationParams, response: { 204: Type.Null() } }, + }, async (request, reply) => { + const claims = await requireAuth(request, tokens, auth) + await collaboration.revokeInvitation(claims.accountId, request.params.workspaceId, request.params.invitationId) + return reply.status(204).send(null) + }) + } +} diff --git a/apps/server/src/routes/workspaces.ts b/apps/server/src/routes/workspaces.ts index dd53225..f607fdc 100644 --- a/apps/server/src/routes/workspaces.ts +++ b/apps/server/src/routes/workspaces.ts @@ -54,9 +54,10 @@ export function createWorkspaceRoutes( app.post('/v1/workspaces', { schema: { headers: IdempotencyKeyHeaders, - body: Type.Intersect([KeyBody, Type.Object({ + body: Type.Object({ nameCiphertext: Type.String({ minLength: 1, maxLength: 8_192 }), - })]), + managedKey: Type.String({ minLength: 40, maxLength: 128 }), + }), response: { 200: Type.Object({ id: Type.String({ format: 'uuid' }), @@ -78,7 +79,7 @@ export function createWorkspaceRoutes( const claims = await requireAuth(request, tokens, auth) const idempotencyKey = typeof request.headers['idempotency-key'] === 'string' ? request.headers['idempotency-key'] : undefined - const result = await workspaces.create(claims.accountId, { + const result = await workspaces.createManagedLibrary(claims.accountId, { ...request.body, ...(idempotencyKey === undefined ? {} : { idempotencyKey }), }) @@ -107,6 +108,13 @@ export function createWorkspaceRoutes( response: { 200: Type.Array(Type.Object({ id: Type.String({ format: 'uuid' }), + ownerAccountId: Type.String({ format: 'uuid' }), + type: Type.Union([Type.Literal('account-data'), Type.Literal('library')]), + owner: Type.Boolean(), + role: Type.Union([ + Type.Literal('owner'), Type.Literal('viewer'), Type.Literal('editor'), Type.Literal('manager'), + ]), + capabilities: Type.Array(Type.String()), nameCiphertext: Type.String(), latestSequence: CounterString, latestKeyVersion: Type.Integer(), @@ -243,6 +251,21 @@ export function createWorkspaceRoutes( return reply.status(204).send(null) }) + app.patch('/v1/workspaces/:workspaceId', { + schema: { + params: WorkspaceParams, + body: Type.Object({ nameCiphertext: Type.String({ minLength: 1, maxLength: 8_192 }) }), + response: { 200: Type.Object({ + id: Type.String({ format: 'uuid' }), + nameCiphertext: Type.String(), + updatedAt: Timestamp, + }) }, + }, + }, async request => { + const claims = await requireAuth(request, tokens, auth) + return workspaces.rename(claims.accountId, request.params.workspaceId, request.body.nameCiphertext) + }) + app.post('/v1/workspaces/:workspaceId/restore', { schema: { params: WorkspaceParams, response: { 204: Type.Null() } }, }, async (request, reply) => { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 59bc531..8898564 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -28,29 +28,19 @@ import { BootstrapService } from './bootstrap/service.js' import { InvitationService } from './invitations/service.js' import { WebStepUpService } from './step-up/service.js' import { IdentityService } from './identity/service.js' -import { EmailIdentityService } from './identity/email-service.js' import { UsageService } from './usage/service.js' -import { EntitlementService } from './billing/service.js' -import { createBillingProvider } from './billing/provider.js' import { createMailProvider } from './mail/provider.js' import { MailOutboxService } from './mail/outbox-service.js' import { MailSecretPayloadService } from './mail/secret-payload-service.js' import { MailAdminService } from './mail/admin-service.js' import { MailOutboxWorker } from './mail/outbox-worker.js' -import { ComplianceService } from './compliance/service.js' -import { DeletionService } from './compliance/deletion-service.js' -import { LegalHoldService } from './compliance/legal-hold-service.js' import { RestoreFenceService } from './restore-fence/service.js' import { RiskService } from './risk/service.js' import { MaintenanceCoordinator } from './maintenance/coordinator.js' -import { SupportService } from './support/service.js' import { BackupInventoryService } from './backup/inventory-service.js' -import { StaffService } from './staff/service.js' -import { StaffSessionService } from './staff/session-service.js' import { AccountServiceAudit } from './audit/service.js' -import { FilesystemDeletionLedgerStore } from './compliance/deletion-ledger-store.js' -import { DeletionLedgerReplayService } from './compliance/deletion-ledger-replay-service.js' import { InstallationService } from './installation/service.js' +import { WorkspaceCollaborationService } from './workspaces/collaboration-service.js' async function main(): Promise { const config = loadConfig() @@ -77,7 +67,7 @@ async function main(): Promise { database = createDatabase(config) await assertMigrationCompatibility(database) const installationProbe = new InstallationService(database, config, true) - const persistedInstallation = await installationProbe.persistedSettings() + let persistedInstallation = await installationProbe.persistedSettings() if (persistedInstallation === undefined) { let finishInstallation!: () => void const installationCompleted = new Promise((resolve) => { finishInstallation = resolve }) @@ -104,19 +94,19 @@ async function main(): Promise { await main() return } - applyPersistedDeploymentProfile( - config, - persistedInstallation.deploymentMode, - persistedInstallation.registrationPolicy === 'public' ? 'public' : 'disabled', - ) + if (persistedInstallation.deploymentMode !== 'self-hosted') { + await installationProbe.migrateLegacyOperationsInstallation() + persistedInstallation = await installationProbe.persistedSettings() + if (persistedInstallation?.deploymentMode !== 'self-hosted') { + throw new Error('Legacy operations mode migration did not complete') + } + } + applyPersistedDeploymentProfile(config, 'self-hosted') const installation = new InstallationService(database, config, false) const deployment = new DeploymentService(database, config) await deployment.initialize() - // A readiness-only failure still leaves a listening process available to - // a misconfigured proxy or direct client. Deployment mode determines the - // business isolation boundary, so a persisted/configured mode mismatch - // (or the legacy hosted public-registration hazard) must prevent service - // assembly and socket binding altogether. + // Keep deployment safety failures ahead of service assembly and socket binding so a + // misconfigured instance cannot become reachable through a proxy. const deploymentSafetyFailure = deployment.getSafetyFailure() if (deploymentSafetyFailure !== undefined) { throw new Error(`Deployment safety gate is closed: ${deploymentSafetyFailure}`) @@ -128,53 +118,23 @@ async function main(): Promise { await restoreFence.reconcile() const maintenanceCoordinator = new MaintenanceCoordinator(database) await maintenanceCoordinator.getSnapshot() - // Restore credential-review restrictions are a self-hosted recovery - // boundary too; the durable restriction evaluator has no hosted-provider - // dependency and must therefore exist in both deployment modes. const accountAudit = new AccountServiceAudit(database) - const staff = config.deploymentMode === 'hosted' ? new StaffService(database) : undefined - const risk = new RiskService(database, config.authSecret, config, staff, accountAudit) - const bootstrap = config.deploymentMode === 'self-hosted' - ? new BootstrapService(database, config, deployment) - : undefined - await bootstrap?.initialize() + const risk = new RiskService(database, config.authSecret, config, undefined, accountAudit) + const bootstrap = new BootstrapService(database, config, deployment) + await bootstrap.initialize() const identities = new IdentityService(database) await identities.backfillLegacyIdentities() const mailOutbox = new MailOutboxService(database) const mailSecrets = new MailSecretPayloadService(database, config.authSecret) const mailProvider = createMailProvider(config) - const mailAdmin = config.deploymentMode === 'self-hosted' && mailProvider !== undefined + const mailAdmin = mailProvider !== undefined ? new MailAdminService(database, config, mailOutbox, mailSecrets, mailProvider) : undefined const invitations = new InvitationService(database, config, deployment, { outbox: mailOutbox, secrets: mailSecrets, deliveryAvailable: () => capabilities.resolvePublic()['mail.delivery'], }) - const emailIdentities = config.deploymentMode === 'hosted' - ? new EmailIdentityService(database, config, mailOutbox, mailSecrets, { - emailVerification: capabilities.resolvePublic()['identity.emailVerification'], - passwordReset: capabilities.resolvePublic()['identity.passwordReset'], - }, risk) : undefined const usage = new UsageService(database) - const entitlements = config.deploymentMode === 'hosted' - ? new EntitlementService(database, config, staff, accountAudit) : undefined - const usageHardEnforcementActive = config.usageEnforcement === 'hard' - && capabilities.resolvePublic()['usage.enforcement'] && entitlements !== undefined - const hardUsageLimitResolver = usageHardEnforcementActive && entitlements !== undefined - ? async (accountId: string) => entitlementLimit(await entitlements.getEffective(accountId), 'storage_bytes') - : undefined - const hardDeviceLimitResolver = usageHardEnforcementActive && entitlements !== undefined - ? async (accountId: string) => entitlementLimit(await entitlements.getEffective(accountId), 'devices') - : undefined - const hardWorkspaceLimitResolver = usageHardEnforcementActive && entitlements !== undefined - ? async (accountId: string) => entitlementLimit(await entitlements.getEffective(accountId), 'workspaces') - : undefined - const billingProvider = createBillingProvider(config) - const compliance = config.deploymentMode === 'hosted' ? new ComplianceService(database, config) : undefined - const legalHolds = config.deploymentMode === 'hosted' && staff !== undefined ? new LegalHoldService(database, config, staff, accountAudit) : undefined - const deletion = config.deploymentMode === 'hosted' ? new DeletionService(database, config, legalHolds, usage, risk) : undefined - const support = config.deploymentMode === 'hosted' ? new SupportService(database, config, staff, accountAudit) : undefined - const staffSessions = staff === undefined ? undefined : new StaffSessionService(database, staff, accountAudit) const instanceId = await getOrCreateInstanceId(database) const syncEpoch = await getOrCreateSyncEpoch(database) const blobStorage: BlobStorage = config.blobStorageDriver === 's3' @@ -188,38 +148,27 @@ async function main(): Promise { const tokens = new TokenService(config.authSecret, config.publicBaseUrl) const totp = new TotpService(config.authSecret) - const auth = new AuthService(database, tokens, totp, risk, - config.deploymentMode === 'hosted' ? usage : undefined, hardDeviceLimitResolver) + const auth = new AuthService(database, tokens, totp, risk) const webSessions = new WebSessionService(database, risk) const stepUps = new WebStepUpService(database, config.authSecret) const deviceAuthorizations = new DeviceAuthorizationService(database, auth) const devicePairings = new DevicePairingService(database, auth) - // Instance administration is a self-hosted authority. Hosted operations - // use the separate staff realm; assembling this service there would leave - // the customer-admin route surface present even when no customer can - // legitimately receive isAdmin. - const admin = config.deploymentMode === 'self-hosted' - ? new AdminService(database, blobStorage, usage, hardWorkspaceLimitResolver, config, deployment) - : undefined - await admin?.recoverInterruptedJobs() + const admin = new AdminService(database, blobStorage, usage, undefined, config, deployment) + await admin.recoverInterruptedJobs() notifier = new PostgresChangeNotifier(database.sql) await notifier.initialize() - const workspaces = new WorkspaceService(database, notifier, - config.deploymentMode === 'hosted' ? usage : undefined, hardWorkspaceLimitResolver) + const workspaces = new WorkspaceService(database, notifier) + const workspaceCollaboration = new WorkspaceCollaborationService(database, workspaces, notifier) const syncProtocol = new DurableSyncService(database, workspaces, notifier, () => config.maxObjectBytes, - config.deploymentMode === 'hosted' ? usage : undefined, hardUsageLimitResolver, syncEpoch) + undefined, undefined, syncEpoch) const blobService = new BlobService( database, workspaces, blobStorage, () => config.maxBlobBytes, config.blobPartBytes, - config.deploymentMode === 'hosted' ? usage : undefined, - hardUsageLimitResolver, syncEpoch, + undefined, undefined, syncEpoch, ) - const deletionLedger = config.deploymentMode === 'hosted' ? new FilesystemDeletionLedgerStore(config.deletionLedgerPath) : undefined - await deletionLedger?.initialize() - await (deletionLedger === undefined ? undefined : new DeletionLedgerReplayService(database, config, deletionLedger).reconcile()) const maintenance = new MaintenanceService( database, blobStorage, config, maintenanceCoordinator, - config.deploymentMode === 'hosted' ? usage : undefined, - deletionLedger, + undefined, + undefined, ) app = await buildApp(config, { @@ -231,6 +180,7 @@ async function main(): Promise { tokens, auth, workspaces, + workspaceCollaboration, syncProtocol, notifier, blobs: blobService, @@ -246,30 +196,16 @@ async function main(): Promise { bootstrap, invitations, identities, - emailIdentities, usage, - usageHardEnforcementActive, - entitlements, - billingProvider, mailProvider, mailOutbox, mailSecrets, mailAdmin, - compliance, - deletion, - legalHolds, risk, - support, - staff, - staffSessions, maintenanceCoordinator, accountAudit, installation, }) - // Hosted internal-test drains to the redacted LogMailProvider; a - // self-hosted instance drains only when its explicitly configured SMTP - // provider assembled successfully. In both cases the durable worker, not - // an HTTP request, owns network delivery and retry semantics. if (mailProvider !== undefined) { stopMailOutbox = new MailOutboxWorker(database, mailOutbox, mailProvider, mailSecrets, { error: (bindings, message) => app?.log.error(bindings, message), @@ -291,8 +227,3 @@ async function main(): Promise { } await main() - -function entitlementLimit(entitlements: { limits: Record }, metric: string): bigint | null { - const value = entitlements.limits[metric] - return value === undefined || value === null ? null : BigInt(value) -} diff --git a/apps/server/src/services.ts b/apps/server/src/services.ts index 39eaeea..1e608ec 100644 --- a/apps/server/src/services.ts +++ b/apps/server/src/services.ts @@ -36,6 +36,7 @@ import type { StaffSessionService } from './staff/session-service.js' import type { WebStepUpService } from './step-up/service.js' import type { AccountServiceAudit } from './audit/service.js' import type { InstallationService } from './installation/service.js' +import type { WorkspaceCollaborationService } from './workspaces/collaboration-service.js' export interface ServiceDependencies { readonly version: string @@ -47,6 +48,7 @@ export interface ServiceDependencies { readonly auth?: AuthService readonly tokens?: TokenService readonly workspaces?: WorkspaceService + readonly workspaceCollaboration?: WorkspaceCollaborationService readonly notifier?: ChangeNotifier readonly blobs?: BlobService readonly webSessions?: WebSessionService diff --git a/apps/server/src/sync/notifier.ts b/apps/server/src/sync/notifier.ts index 5a0f5b9..66b7655 100644 --- a/apps/server/src/sync/notifier.ts +++ b/apps/server/src/sync/notifier.ts @@ -91,6 +91,12 @@ function parseNotice(payload: string): SyncNotice | null { if (candidate.type === 'workspace.state-changed' && typeof candidate.deleted === 'boolean') { return { type: 'workspace.state-changed', workspaceId: candidate.workspaceId, deleted: candidate.deleted } } + if (candidate.type === 'workspace.members-changed') { + return { type: 'workspace.members-changed', workspaceId: candidate.workspaceId } + } + if (candidate.type === 'workspace.metadata-changed') { + return { type: 'workspace.metadata-changed', workspaceId: candidate.workspaceId } + } return null } catch { return null diff --git a/apps/server/src/sync/types.ts b/apps/server/src/sync/types.ts index 97fd1f6..75b0ad9 100644 --- a/apps/server/src/sync/types.ts +++ b/apps/server/src/sync/types.ts @@ -1,5 +1,5 @@ export const syncObjectKinds = [ - 'note', 'folder', 'asset', 'canvas', 'record', 'tag', 'mark', 'conversation', + 'note', 'folder', 'asset', 'canvas', 'tag', 'mark', 'conversation', 'message', 'memory', 'setting', 'yjs-checkpoint', 'yjs-update', ] as const @@ -40,8 +40,19 @@ export interface AccountWorkspacesChangedNotice { accountId: string } +export interface WorkspaceMembersChangedNotice { + type: 'workspace.members-changed' + workspaceId: string +} + +export interface WorkspaceMetadataChangedNotice { + type: 'workspace.metadata-changed' + workspaceId: string +} + export type SyncNotice = WorkspaceChangeNotice | WorkspaceKeysChangedNotice - | WorkspaceStateChangedNotice | AccountWorkspacesChangedNotice + | WorkspaceStateChangedNotice | AccountWorkspacesChangedNotice | WorkspaceMembersChangedNotice + | WorkspaceMetadataChangedNotice export interface ChangeNotifier { publish(notice: SyncNotice): Promise diff --git a/apps/server/src/usage/service.ts b/apps/server/src/usage/service.ts index 49c158e..460a96e 100644 --- a/apps/server/src/usage/service.ts +++ b/apps/server/src/usage/service.ts @@ -123,6 +123,7 @@ export class UsageService { active_object_bytes: string active_crdt_bytes: string active_blob_bytes: string + retained_bytes: string active_objects: string active_devices: string active_workspaces: string @@ -139,6 +140,34 @@ export class UsageService { where w.account_id = ${accountId} and w.deleted_at is null), 0))::text as active_crdt_bytes, coalesce((select sum(b.size)::bigint from blobs b join workspaces w on w.id = b.workspace_id where w.account_id = ${accountId} and w.deleted_at is null and b.state = 'ready'), 0)::text as active_blob_bytes, + (coalesce((select sum(length(v.ciphertext))::bigint + from object_versions v join workspaces w on w.id = v.workspace_id + where w.account_id = ${accountId}), 0) + + coalesce((select sum(length(e.ciphertext))::bigint + from sync_events e join workspaces w on w.id = e.workspace_id + where w.account_id = ${accountId} and e.ciphertext is not null), 0) + + coalesce((select sum(length(c.ciphertext))::bigint + from sync_checkpoints c join workspaces w on w.id = c.workspace_id + where w.account_id = ${accountId}), 0) + + coalesce((select sum(length(c.ciphertext))::bigint + from sync_conflicts c join workspaces w on w.id = c.workspace_id + where w.account_id = ${accountId}), 0) + + coalesce((select sum(pg_column_size(c.result) + length(c.request_hash))::bigint + from sync_commands c join workspaces w on w.id = c.workspace_id + where w.account_id = ${accountId}), 0) + + coalesce((select sum(length(o.ciphertext))::bigint + from objects o join workspaces w on w.id = o.workspace_id + where w.account_id = ${accountId} and (w.deleted_at is not null or o.deleted_at is not null)), 0) + + coalesce((select sum(length(d.checkpoint_ciphertext))::bigint + from sync_documents d join workspaces w on w.id = d.workspace_id + where w.account_id = ${accountId} and w.deleted_at is not null + and d.checkpoint_ciphertext is not null), 0) + + coalesce((select sum(length(u.ciphertext))::bigint + from sync_updates u join workspaces w on w.id = u.workspace_id + where w.account_id = ${accountId} and w.deleted_at is not null), 0) + + coalesce((select sum(b.size)::bigint + from blobs b join workspaces w on w.id = b.workspace_id + where w.account_id = ${accountId} and w.deleted_at is not null and b.state = 'ready'), 0))::text as retained_bytes, coalesce((select count(*) from objects o join workspaces w on w.id = o.workspace_id where w.account_id = ${accountId} and w.deleted_at is null and o.deleted_at is null), 0)::text as active_objects, coalesce((select count(*) from devices d where d.account_id = ${accountId} and d.revoked_at is null), 0)::text as active_devices, @@ -148,13 +177,14 @@ export class UsageService { const changed = existing.activeObjectBytes !== BigInt(actual.active_object_bytes) || existing.activeCrdtBytes !== BigInt(actual.active_crdt_bytes) || existing.activeBlobBytes !== BigInt(actual.active_blob_bytes) + || existing.retainedBytes !== BigInt(actual.retained_bytes) || existing.activeObjects !== BigInt(actual.active_objects) || existing.activeDevices !== BigInt(actual.active_devices) || existing.activeWorkspaces !== BigInt(actual.active_workspaces) const [row] = await tx.update(accountUsage).set({ ...(changed ? { activeObjectBytes: BigInt(actual.active_object_bytes), activeBlobBytes: BigInt(actual.active_blob_bytes), - activeCrdtBytes: BigInt(actual.active_crdt_bytes), + activeCrdtBytes: BigInt(actual.active_crdt_bytes), retainedBytes: BigInt(actual.retained_bytes), activeObjects: BigInt(actual.active_objects), activeDevices: BigInt(actual.active_devices), activeWorkspaces: BigInt(actual.active_workspaces), revision: sql`${accountUsage.revision} + 1`, } : {}), @@ -174,7 +204,14 @@ export class UsageService { }): Promise<{ reservationId: string, created: boolean }> { if (input.bytes <= 0n) throw new ApiError({ code: 'quota_invalid_request', message: 'Reserved bytes must be positive', statusCode: 400 }) return await this.database.db.transaction(async (tx) => { - await tx.insert(accountUsage).values({ accountId: input.accountId }).onConflictDoNothing() + if (input.storageLimit === null) { + await tx.insert(accountUsage).values({ accountId: input.accountId }).onConflictDoNothing() + } else { + // Hard-limit admission starts from the physical durable tables so + // retained history written since the last background reconciliation + // cannot open a temporary quota bypass window. + await this.reconcileCurrentInTransaction(tx, input.accountId) + } const [existing] = await tx.select({ id: usageReservations.id, requestHash: usageReservations.requestHash, quantity: usageReservations.quantity, status: usageReservations.status, @@ -272,13 +309,10 @@ export class UsageService { revision: sql`${accountUsage.revision} + 1`, updatedAt: new Date(), }).where(and( eq(accountUsage.accountId, input.accountId), - ...(input.storageLimit === null || byteDelta <= 0n ? [] : [sql`${accountUsage.activeObjectBytes} + ${accountUsage.activeCrdtBytes} + ${accountUsage.activeBlobBytes} + ${accountUsage.reservedBlobBytes} + ${byteDelta} <= ${input.storageLimit}`]), + ...(input.storageLimit === null || byteDelta <= 0n ? [] : [sql`${accountUsage.activeObjectBytes} + ${accountUsage.activeCrdtBytes} + ${accountUsage.activeBlobBytes} + ${accountUsage.reservedBlobBytes} + ${accountUsage.retainedBytes} + ${byteDelta} <= ${input.storageLimit}`]), )).returning({ accountId: accountUsage.accountId }) if (updated.length !== 1) { - throw new ApiError({ - code: 'quota_exceeded', message: 'Storage quota is exceeded', statusCode: 409, - details: { metric: 'storage_bytes', limit: input.storageLimit?.toString() }, - }) + throw await storageQuotaError(tx, input.accountId, input.storageLimit) } } @@ -295,11 +329,10 @@ export class UsageService { revision: sql`${accountUsage.revision} + 1`, updatedAt: new Date(), }).where(and( eq(accountUsage.accountId, accountId), - ...(storageLimit === null || byteDelta <= 0n ? [] : [sql`${accountUsage.activeObjectBytes} + ${accountUsage.activeCrdtBytes} + ${accountUsage.activeBlobBytes} + ${accountUsage.reservedBlobBytes} + ${byteDelta} <= ${storageLimit}`]), + ...(storageLimit === null || byteDelta <= 0n ? [] : [sql`${accountUsage.activeObjectBytes} + ${accountUsage.activeCrdtBytes} + ${accountUsage.activeBlobBytes} + ${accountUsage.reservedBlobBytes} + ${accountUsage.retainedBytes} + ${byteDelta} <= ${storageLimit}`]), )).returning({ accountId: accountUsage.accountId }) if (updated.length !== 1) { - throw new ApiError({ code: 'quota_exceeded', message: 'Storage quota is exceeded', statusCode: 409, - details: { metric: 'storage_bytes', limit: storageLimit?.toString() } }) + throw await storageQuotaError(tx, accountId, storageLimit) } } @@ -353,8 +386,8 @@ async function reserveBytes( const admitted = await tx.update(accountUsage).set({ reservedBlobBytes: sql`${accountUsage.reservedBlobBytes} + ${bytes}`, revision: sql`${accountUsage.revision} + 1`, updatedAt: new Date(), - }).where(and(eq(accountUsage.accountId, accountId), sql`${accountUsage.activeObjectBytes} + ${accountUsage.activeCrdtBytes} + ${accountUsage.activeBlobBytes} + ${accountUsage.reservedBlobBytes} + ${bytes} <= ${storageLimit}`)).returning({ accountId: accountUsage.accountId }) - if (admitted.length !== 1) throw new ApiError({ code: 'quota_exceeded', message: 'Storage quota is exceeded', statusCode: 409, details: { metric: 'storage_bytes', limit: storageLimit.toString() } }) + }).where(and(eq(accountUsage.accountId, accountId), sql`${accountUsage.activeObjectBytes} + ${accountUsage.activeCrdtBytes} + ${accountUsage.activeBlobBytes} + ${accountUsage.reservedBlobBytes} + ${accountUsage.retainedBytes} + ${bytes} <= ${storageLimit}`)).returning({ accountId: accountUsage.accountId }) + if (admitted.length !== 1) throw await storageQuotaError(tx, accountId, storageLimit) return } await tx.update(accountUsage).set({ @@ -363,6 +396,28 @@ async function reserveBytes( }).where(eq(accountUsage.accountId, accountId)) } +async function storageQuotaError(tx: any, accountId: string, limit: bigint | null): Promise { + const [usage] = await tx.select({ + activeObjectBytes: accountUsage.activeObjectBytes, + activeCrdtBytes: accountUsage.activeCrdtBytes, + activeBlobBytes: accountUsage.activeBlobBytes, + reservedBlobBytes: accountUsage.reservedBlobBytes, + retainedBytes: accountUsage.retainedBytes, + }).from(accountUsage).where(eq(accountUsage.accountId, accountId)).limit(1) + const used = usage === undefined ? 0n + : usage.activeObjectBytes + usage.activeCrdtBytes + usage.activeBlobBytes + + usage.reservedBlobBytes + usage.retainedBytes + return new ApiError({ + code: 'quota_exceeded', message: 'Storage quota is exceeded', statusCode: 409, + details: { + metric: 'storage_bytes', + limit: limit?.toString(), + used: used.toString(), + reserved: usage?.reservedBlobBytes.toString() ?? '0', + }, + }) +} + function usageEventRequestHash(value: Record): string { const canonical = Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) return createHash('sha256').update(JSON.stringify(canonical)).digest('base64url') diff --git a/apps/server/src/workspaces/capabilities.ts b/apps/server/src/workspaces/capabilities.ts new file mode 100644 index 0000000..24070b0 --- /dev/null +++ b/apps/server/src/workspaces/capabilities.ts @@ -0,0 +1,43 @@ +export const workspaceCapabilities = [ + 'content.read', + 'content.create', + 'content.update', + 'content.delete', + 'history.view', + 'history.restore', + 'member.invite', + 'member.update', + 'member.remove', + 'workspace.rename', + 'workspace.delete', +] as const + +export type WorkspaceCapability = typeof workspaceCapabilities[number] +export type WorkspaceMemberRole = 'viewer' | 'editor' | 'manager' + +export const workspaceRoleTemplates: Readonly> = { + viewer: ['content.read', 'history.view'], + editor: [ + 'content.read', 'content.create', 'content.update', 'content.delete', + 'history.view', 'history.restore', + ], + manager: [ + 'content.read', 'content.create', 'content.update', 'content.delete', + 'history.view', 'history.restore', 'member.invite', 'member.update', 'member.remove', + 'workspace.rename', + ], +} + +const capabilitySet = new Set(workspaceCapabilities) + +export function normalizeWorkspaceCapabilities(values: readonly string[]): WorkspaceCapability[] { + const unique = [...new Set(values)] + if (unique.some(value => !capabilitySet.has(value))) { + throw new Error('Unknown workspace capability') + } + return workspaceCapabilities.filter(capability => unique.includes(capability)) +} + +export function capabilitiesForRole(role: WorkspaceMemberRole): WorkspaceCapability[] { + return [...workspaceRoleTemplates[role]] +} diff --git a/apps/server/src/workspaces/collaboration-service.ts b/apps/server/src/workspaces/collaboration-service.ts new file mode 100644 index 0000000..0d215b9 --- /dev/null +++ b/apps/server/src/workspaces/collaboration-service.ts @@ -0,0 +1,337 @@ +import { createHash, randomBytes } from 'node:crypto' +import { and, eq, isNull, sql } from 'drizzle-orm' +import type { DatabaseContext } from '../database/client.js' +import { + accounts, workspaceInvitations, workspaceMembers, workspaces, +} from '../database/schema.js' +import { ApiError } from '../errors.js' +import type { ChangeNotifier } from '../sync/types.js' +import { + capabilitiesForRole, normalizeWorkspaceCapabilities, workspaceCapabilities, + type WorkspaceCapability, type WorkspaceMemberRole, +} from './capabilities.js' +import type { WorkspaceService } from './service.js' +import type { WorkspaceAccess } from './service.js' + +interface MembershipInput { + role: WorkspaceMemberRole + capabilities?: string[] +} + +export class WorkspaceCollaborationService { + constructor( + private readonly database: DatabaseContext, + private readonly workspacesService: WorkspaceService, + private readonly notifier: ChangeNotifier, + ) {} + + async listMembers(accountId: string, workspaceId: string) { + await this.workspacesService.assertCapability(accountId, workspaceId, 'content.read') + const [workspace] = await this.database.db.select({ + ownerAccountId: workspaces.accountId, + ownerLogin: accounts.login, + type: workspaces.type, + }).from(workspaces).innerJoin(accounts, eq(accounts.id, workspaces.accountId)) + .where(and(eq(workspaces.id, workspaceId), isNull(workspaces.deletedAt))).limit(1) + if (workspace === undefined) throw workspaceNotFound() + const members = await this.database.db.select({ + accountId: workspaceMembers.accountId, + login: accounts.login, + role: workspaceMembers.role, + capabilities: workspaceMembers.capabilities, + joinedAt: workspaceMembers.joinedAt, + updatedAt: workspaceMembers.updatedAt, + }).from(workspaceMembers).innerJoin(accounts, eq(accounts.id, workspaceMembers.accountId)) + .where(eq(workspaceMembers.workspaceId, workspaceId)) + .orderBy(workspaceMembers.joinedAt) + return [{ + accountId: workspace.ownerAccountId, + login: workspace.ownerLogin, + role: 'owner' as const, + capabilities: workspaceCapabilities, + joinedAt: null, + updatedAt: null, + }, ...members] + } + + async listInvitations(accountId: string, workspaceId: string) { + await this.#assertShareable(accountId, workspaceId, 'member.invite') + const rows = await this.database.db.select().from(workspaceInvitations).where(and( + eq(workspaceInvitations.workspaceId, workspaceId), + eq(workspaceInvitations.status, 'pending'), + )).orderBy(workspaceInvitations.createdAt) + return rows.map(serializeInvitation) + } + + async listPendingForAccount(accountId: string) { + const rows = await this.database.db.select({ + invitation: workspaceInvitations, + workspaceNameCiphertext: workspaces.nameCiphertext, + inviterLogin: accounts.login, + }).from(workspaceInvitations) + .innerJoin(workspaces, eq(workspaces.id, workspaceInvitations.workspaceId)) + .innerJoin(accounts, eq(accounts.id, workspaceInvitations.invitedByAccountId)) + .where(and( + eq(workspaceInvitations.kind, 'account'), + eq(workspaceInvitations.inviteeAccountId, accountId), + eq(workspaceInvitations.status, 'pending'), + isNull(workspaces.deletedAt), + )).orderBy(workspaceInvitations.createdAt) + return rows.map(row => ({ + ...serializeInvitation(row.invitation), + workspaceNameCiphertext: row.workspaceNameCiphertext, + inviterLogin: row.inviterLogin, + })) + } + + async inviteAccount( + actorAccountId: string, + workspaceId: string, + login: string, + input: MembershipInput, + expiresInSeconds = 7 * 24 * 60 * 60, + ) { + const actor = await this.#assertShareable(actorAccountId, workspaceId, 'member.invite') + const normalizedLogin = login.trim().toLocaleLowerCase('en-US') + const [invitee] = await this.database.db.select({ id: accounts.id, login: accounts.login }) + .from(accounts).where(and( + sql`lower(${accounts.login}) = ${normalizedLogin}`, + isNull(accounts.disabledAt), + isNull(accounts.suspendedAt), + )).limit(1) + if (invitee === undefined) { + throw new ApiError({ code: 'invitee_not_found', message: 'Invitee account was not found', statusCode: 404 }) + } + const [workspace] = await this.database.db.select({ ownerAccountId: workspaces.accountId }) + .from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1) + if (workspace?.ownerAccountId === invitee.id) { + throw new ApiError({ code: 'workspace_owner_already_member', message: 'Workspace owner is already a member', statusCode: 409 }) + } + const capabilities = membershipCapabilities(input) + assertGrantable(actor, capabilities) + const [invitation] = await this.database.db.insert(workspaceInvitations).values({ + workspaceId, + kind: 'account', + inviteeAccountId: invitee.id, + role: input.role, + capabilities, + invitedByAccountId: actorAccountId, + expiresAt: new Date(Date.now() + expiresInSeconds * 1_000), + }).onConflictDoUpdate({ + target: [workspaceInvitations.workspaceId, workspaceInvitations.inviteeAccountId], + targetWhere: sql`${workspaceInvitations.kind} = 'account' and ${workspaceInvitations.status} = 'pending'`, + set: { + role: input.role, + capabilities, + invitedByAccountId: actorAccountId, + expiresAt: new Date(Date.now() + expiresInSeconds * 1_000), + updatedAt: new Date(), + }, + }).returning() + if (invitation === undefined) throw new Error('Workspace invitation insert returned no row') + await this.#publishMembershipChanged(workspaceId, invitee.id) + return { ...serializeInvitation(invitation), inviteeLogin: invitee.login } + } + + async createLink( + actorAccountId: string, + workspaceId: string, + input: MembershipInput, + expiresInSeconds = 7 * 24 * 60 * 60, + ) { + const actor = await this.#assertShareable(actorAccountId, workspaceId, 'member.invite') + const capabilities = membershipCapabilities(input) + assertGrantable(actor, capabilities) + const token = randomBytes(32).toString('base64url') + const [invitation] = await this.database.db.insert(workspaceInvitations).values({ + workspaceId, + kind: 'link', + tokenHash: tokenDigest(token), + tokenHint: token.slice(-6), + role: input.role, + capabilities, + invitedByAccountId: actorAccountId, + expiresAt: new Date(Date.now() + expiresInSeconds * 1_000), + }).returning() + if (invitation === undefined) throw new Error('Workspace invitation insert returned no row') + return { ...serializeInvitation(invitation), token } + } + + async acceptAccountInvitation(accountId: string, invitationId: string) { + return this.#accept(accountId, and( + eq(workspaceInvitations.id, invitationId), + eq(workspaceInvitations.kind, 'account'), + eq(workspaceInvitations.inviteeAccountId, accountId), + )) + } + + async acceptLink(accountId: string, token: string) { + return this.#accept(accountId, and( + eq(workspaceInvitations.tokenHash, tokenDigest(token)), + eq(workspaceInvitations.kind, 'link'), + )) + } + + async updateMember( + actorAccountId: string, + workspaceId: string, + memberAccountId: string, + input: MembershipInput, + ) { + const actor = await this.#assertShareable(actorAccountId, workspaceId, 'member.update') + const capabilities = membershipCapabilities(input) + assertGrantable(actor, capabilities) + const [member] = await this.database.db.update(workspaceMembers).set({ + role: input.role, + capabilities, + updatedAt: new Date(), + }).where(and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.accountId, memberAccountId), + )).returning() + if (member === undefined) throw memberNotFound() + const [account] = await this.database.db.select({ login: accounts.login }) + .from(accounts).where(eq(accounts.id, memberAccountId)).limit(1) + if (account === undefined) throw memberNotFound() + await this.#publishMembershipChanged(workspaceId, memberAccountId) + return { + accountId: member.accountId, + login: account.login, + role: member.role, + capabilities: member.capabilities, + joinedAt: member.joinedAt, + updatedAt: member.updatedAt, + } + } + + async removeMember(actorAccountId: string, workspaceId: string, memberAccountId: string): Promise { + await this.#assertShareable(actorAccountId, workspaceId, 'member.remove') + const removed = await this.database.db.delete(workspaceMembers).where(and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.accountId, memberAccountId), + )).returning({ accountId: workspaceMembers.accountId }) + if (removed.length === 0) throw memberNotFound() + await this.#publishMembershipChanged(workspaceId, memberAccountId) + } + + async revokeInvitation(actorAccountId: string, workspaceId: string, invitationId: string): Promise { + await this.#assertShareable(actorAccountId, workspaceId, 'member.invite') + const [revoked] = await this.database.db.update(workspaceInvitations).set({ + status: 'revoked', revokedAt: new Date(), updatedAt: new Date(), + }).where(and( + eq(workspaceInvitations.id, invitationId), + eq(workspaceInvitations.workspaceId, workspaceId), + eq(workspaceInvitations.status, 'pending'), + )).returning({ id: workspaceInvitations.id }) + if (revoked === undefined) { + throw new ApiError({ code: 'workspace_invitation_not_found', message: 'Pending invitation was not found', statusCode: 404 }) + } + await this.notifier.publish({ type: 'workspace.members-changed', workspaceId }).catch(() => undefined) + } + + async #accept(accountId: string, predicate: ReturnType) { + const accepted = await this.database.db.transaction(async tx => { + const [invitation] = await tx.select().from(workspaceInvitations) + .where(and(predicate, eq(workspaceInvitations.status, 'pending'))).limit(1).for('update') + if (invitation === undefined) { + throw new ApiError({ code: 'workspace_invitation_not_found', message: 'Pending invitation was not found', statusCode: 404 }) + } + if (invitation.expiresAt <= new Date()) { + await tx.update(workspaceInvitations).set({ status: 'expired', updatedAt: new Date() }) + .where(eq(workspaceInvitations.id, invitation.id)) + return { expired: true as const, workspaceId: invitation.workspaceId } + } + const [workspace] = await tx.select({ ownerAccountId: workspaces.accountId, type: workspaces.type }) + .from(workspaces).where(and(eq(workspaces.id, invitation.workspaceId), isNull(workspaces.deletedAt))).limit(1) + if (workspace === undefined) throw workspaceNotFound() + if (workspace.type !== 'library') throw accountDataCannotBeShared() + if (workspace.ownerAccountId === accountId) { + throw new ApiError({ code: 'workspace_owner_already_member', message: 'Workspace owner is already a member', statusCode: 409 }) + } + await tx.insert(workspaceMembers).values({ + workspaceId: invitation.workspaceId, + accountId, + role: invitation.role, + capabilities: invitation.capabilities, + invitedByAccountId: invitation.invitedByAccountId, + }).onConflictDoUpdate({ + target: [workspaceMembers.workspaceId, workspaceMembers.accountId], + set: { + role: invitation.role, + capabilities: invitation.capabilities, + invitedByAccountId: invitation.invitedByAccountId, + updatedAt: new Date(), + }, + }) + await tx.update(workspaceInvitations).set({ + status: 'accepted', acceptedByAccountId: accountId, + acceptedAt: new Date(), updatedAt: new Date(), + }).where(eq(workspaceInvitations.id, invitation.id)) + return { expired: false as const, workspaceId: invitation.workspaceId } + }) + if (accepted.expired) { + throw new ApiError({ code: 'workspace_invitation_expired', message: 'Invitation has expired', statusCode: 410 }) + } + await this.#publishMembershipChanged(accepted.workspaceId, accountId) + return { workspaceId: accepted.workspaceId } + } + + async #assertShareable( + accountId: string, + workspaceId: string, + capability: 'member.invite' | 'member.update' | 'member.remove', + ): Promise { + const access = await this.workspacesService.assertCapability(accountId, workspaceId, capability) + if (access.type !== 'library') throw accountDataCannotBeShared() + return access + } + + async #publishMembershipChanged(workspaceId: string, accountId: string): Promise { + await Promise.all([ + this.notifier.publish({ type: 'workspace.members-changed', workspaceId }).catch(() => undefined), + this.notifier.publish({ type: 'account.workspaces-changed', accountId }).catch(() => undefined), + ]) + } +} + +function membershipCapabilities(input: MembershipInput): WorkspaceCapability[] { + try { + return normalizeWorkspaceCapabilities(input.capabilities ?? capabilitiesForRole(input.role)) + } catch { + throw new ApiError({ code: 'workspace_capability_invalid', message: 'Workspace capability list is invalid', statusCode: 400 }) + } +} + +function tokenDigest(token: string): string { + return createHash('sha256').update(token).digest('base64url') +} + +function serializeInvitation(invitation: typeof workspaceInvitations.$inferSelect) { + const { tokenHash: _tokenHash, ...safe } = invitation + return safe +} + +function workspaceNotFound(): ApiError { + return new ApiError({ code: 'workspace_not_found', message: 'Workspace not found', statusCode: 404 }) +} + +function memberNotFound(): ApiError { + return new ApiError({ code: 'workspace_member_not_found', message: 'Workspace member was not found', statusCode: 404 }) +} + +function accountDataCannotBeShared(): ApiError { + return new ApiError({ + code: 'account_data_workspace_not_shareable', + message: 'Personal account-data workspaces cannot be shared', + statusCode: 409, + }) +} + +function assertGrantable(actor: WorkspaceAccess, capabilities: readonly WorkspaceCapability[]): void { + if (actor.owner || capabilities.every(capability => actor.capabilities.includes(capability))) return + throw new ApiError({ + code: 'workspace_capability_escalation_denied', + message: 'Members cannot grant capabilities they do not hold', + statusCode: 403, + }) +} diff --git a/apps/server/src/workspaces/service.ts b/apps/server/src/workspaces/service.ts index 62b6caf..c5bbca8 100644 --- a/apps/server/src/workspaces/service.ts +++ b/apps/server/src/workspaces/service.ts @@ -1,11 +1,12 @@ import { createHash } from 'node:crypto' -import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { DatabaseContext } from '../database/client.js' -import { devices, objects, workspaceKeyEnvelopes, workspaceKeys, workspaces } from '../database/schema.js' +import { devices, objects, workspaceKeyEnvelopes, workspaceKeys, workspaceMembers, workspaces } from '../database/schema.js' import { ApiError } from '../errors.js' import { assertAccountWriteAllowedInTransaction } from '../compliance/deletion-fence.js' import type { ChangeNotifier } from '../sync/types.js' import type { UsageService } from '../usage/service.js' +import { workspaceCapabilities, type WorkspaceCapability } from './capabilities.js' export interface CreateWorkspaceInput { nameCiphertext: string @@ -22,6 +23,14 @@ export interface KeyEnvelopeInput { kdfParams: Record | null } +export interface WorkspaceAccess { + ownerAccountId: string + owner: boolean + role: 'owner' | 'viewer' | 'editor' | 'manager' + capabilities: WorkspaceCapability[] + type: 'account-data' | 'library' +} + export class WorkspaceService { constructor( private readonly database: DatabaseContext, @@ -31,7 +40,8 @@ export class WorkspaceService { ) {} async create(accountId: string, input: CreateWorkspaceInput): Promise<{ id: string, createdAt: Date, latestSequence: string, nameCiphertext: string, created: boolean }> { - validateKeyEnvelopes(input.envelopes, true) + const managed = input.envelopes.length === 1 && input.envelopes[0]?.type === 'managed' + validateKeyEnvelopes(input.envelopes, !managed, managed) await this.#validateDeviceRecipients(accountId, input.envelopes) const workspaceLimit = await this.#workspaceLimit(accountId) const requestHash = input.idempotencyKey === undefined ? undefined : workspaceCreationHash(input) @@ -52,16 +62,12 @@ export class WorkspaceService { return { ...existing, latestSequence: existing.latestSequence.toString(), created: false } } } - const [currentDefault] = await tx.select({ id: workspaces.id }).from(workspaces).where(and( - eq(workspaces.accountId, accountId), - eq(workspaces.isDefault, true), - isNull(workspaces.deletedAt), - )).limit(1) await this.usage?.admitWorkspace(tx, accountId, workspaceLimit) const [workspace] = await tx.insert(workspaces).values({ accountId, + type: 'library', nameCiphertext: input.nameCiphertext, - isDefault: currentDefault === undefined, + isDefault: false, ...(input.idempotencyKey === undefined ? {} : { creationIdempotencyKey: input.idempotencyKey, creationRequestHash: requestHash, @@ -78,6 +84,29 @@ export class WorkspaceService { return created } + async createManagedLibrary(accountId: string, input: { + nameCiphertext: string + managedKey: string + idempotencyKey?: string + }) { + if (!/^[A-Za-z0-9_-]{43}$/.test(input.managedKey)) { + throw new ApiError({ + code: 'managed_key_invalid', + message: 'Managed key must be a 256-bit Base64URL value', + statusCode: 400, + }) + } + return this.create(accountId, { + nameCiphertext: input.nameCiphertext, + keyVersion: 1, + envelopes: [{ + type: 'managed', recipientId: null, wrappedKey: input.managedKey, + kdfSalt: null, kdfParams: null, + }], + ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }), + }) + } + async getCreationRequest(accountId: string, idempotencyKey: string): Promise<{ id: string, createdAt: Date } | undefined> { const [workspace] = await this.database.db.select({ id: workspaces.id, createdAt: workspaces.createdAt }) .from(workspaces).where(and( @@ -116,7 +145,7 @@ export class WorkspaceService { nameCiphertext: workspaces.nameCiphertext, }).from(workspaces).where(and( eq(workspaces.accountId, accountId), - eq(workspaces.isDefault, true), + eq(workspaces.type, 'account-data'), isNull(workspaces.deletedAt), )).limit(1) if (existing !== undefined) { @@ -141,6 +170,7 @@ export class WorkspaceService { const [workspace] = await tx.insert(workspaces).values({ accountId, + type: 'account-data', nameCiphertext: input.nameCiphertext, isDefault: true, }).returning({ @@ -170,13 +200,20 @@ export class WorkspaceService { async list(accountId: string, deviceId: string, includeDeleted: boolean) { const rows = await this.database.db.select({ id: workspaces.id, + ownerAccountId: workspaces.accountId, + type: workspaces.type, nameCiphertext: workspaces.nameCiphertext, latestSequence: workspaces.latestSequence, createdAt: workspaces.createdAt, updatedAt: workspaces.updatedAt, deletedAt: workspaces.deletedAt, - }).from(workspaces).where(and( - eq(workspaces.accountId, accountId), + memberRole: workspaceMembers.role, + memberCapabilities: workspaceMembers.capabilities, + }).from(workspaces).leftJoin(workspaceMembers, and( + eq(workspaceMembers.workspaceId, workspaces.id), + eq(workspaceMembers.accountId, accountId), + )).where(and( + or(eq(workspaces.accountId, accountId), eq(workspaceMembers.accountId, accountId)), ...(includeDeleted ? [] : [isNull(workspaces.deletedAt)]), )) .orderBy(desc(workspaces.updatedAt)) @@ -208,6 +245,11 @@ export class WorkspaceService { const managedWorkspaceIds = new Set(managedEnvelopes.map((item) => item.workspaceId)) return rows.map((row) => ({ ...row, + owner: row.ownerAccountId === accountId, + role: row.ownerAccountId === accountId ? 'owner' as const : row.memberRole, + capabilities: row.ownerAccountId === accountId + ? [...workspaceCapabilities] + : row.memberCapabilities ?? [], latestSequence: row.latestSequence.toString(), latestKeyVersion: latestKeyVersionByWorkspace.get(row.id) ?? 0, hasDeviceEnvelope: deviceWorkspaceIds.has(row.id), @@ -216,7 +258,7 @@ export class WorkspaceService { } async getAccountSyncOverview(accountId: string) { - const [summaries, kinds, recentActivity] = await Promise.all([ + const [summaries, kinds, recentActivity, usage] = await Promise.all([ this.database.sql ({ kind: item.kind, activeCount: item.active_count, @@ -384,8 +434,63 @@ export class WorkspaceService { } } + async access(accountId: string, workspaceId: string): Promise { + const [workspace] = await this.database.db.select({ + ownerAccountId: workspaces.accountId, + type: workspaces.type, + memberRole: workspaceMembers.role, + memberCapabilities: workspaceMembers.capabilities, + }).from(workspaces).leftJoin(workspaceMembers, and( + eq(workspaceMembers.workspaceId, workspaces.id), + eq(workspaceMembers.accountId, accountId), + )).where(and( + eq(workspaces.id, workspaceId), + isNull(workspaces.deletedAt), + or(eq(workspaces.accountId, accountId), eq(workspaceMembers.accountId, accountId)), + )).limit(1) + if (workspace === undefined) { + throw new ApiError({ code: 'workspace_not_found', message: 'Workspace not found', statusCode: 404 }) + } + if (workspace.ownerAccountId === accountId) { + return { + ownerAccountId: workspace.ownerAccountId, + owner: true, + role: 'owner', + capabilities: [...workspaceCapabilities], + type: workspace.type, + } + } + if (workspace.memberRole === null) { + throw new ApiError({ code: 'workspace_not_found', message: 'Workspace not found', statusCode: 404 }) + } + return { + ownerAccountId: workspace.ownerAccountId, + owner: false, + role: workspace.memberRole, + capabilities: workspace.memberCapabilities as WorkspaceCapability[], + type: workspace.type, + } + } + + async assertCapability( + accountId: string, + workspaceId: string, + capability: WorkspaceCapability, + ): Promise { + const access = await this.access(accountId, workspaceId) + if (!access.capabilities.includes(capability)) { + throw new ApiError({ + code: 'workspace_capability_denied', + message: `Workspace capability ${capability} is required`, + statusCode: 403, + details: { capability }, + }) + } + return access + } + async listKeys(accountId: string, workspaceId: string) { - await this.assertOwned(accountId, workspaceId) + await this.assertCapability(accountId, workspaceId, 'content.read') const keys = await this.database.db.select({ keyVersion: workspaceKeys.keyVersion, createdAt: workspaceKeys.createdAt, @@ -610,18 +715,28 @@ export class WorkspaceService { workspaceId: string, options: { allowDefault?: boolean } = {}, ): Promise { - await this.assertOwned(accountId, workspaceId) + const access = await this.assertCapability(accountId, workspaceId, 'workspace.delete') const removed = await this.database.db.transaction(async (tx) => { await assertAccountWriteAllowedInTransaction(tx, accountId) - const [current] = await tx.select({ id: workspaces.id, deletedAt: workspaces.deletedAt }).from(workspaces) - .where(and(eq(workspaces.id, workspaceId), eq(workspaces.accountId, accountId), + const [current] = await tx.select({ + id: workspaces.id, deletedAt: workspaces.deletedAt, + type: workspaces.type, ownerAccountId: workspaces.accountId, + }).from(workspaces) + .where(and(eq(workspaces.id, workspaceId), ...(options.allowDefault === false ? [eq(workspaces.isDefault, false)] : []))) .limit(1).for('update') if (current === undefined) return undefined + if (current.type === 'account-data') { + throw new ApiError({ + code: 'account_data_workspace_delete_forbidden', + message: 'Personal account-data workspace cannot be deleted', + statusCode: 409, + }) + } if (current.deletedAt === null) { await tx.update(workspaces).set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(workspaces.id, workspaceId)) - await this.usage?.releaseWorkspace(tx, accountId) + await this.usage?.releaseWorkspace(tx, access.ownerAccountId) } return current }) @@ -636,6 +751,24 @@ export class WorkspaceService { type: 'workspace.state-changed', workspaceId, deleted: true, }).catch(() => undefined) await this.#publishWorkspaceListChanged(accountId) + if (access.ownerAccountId !== accountId) await this.#publishWorkspaceListChanged(access.ownerAccountId) + } + + async rename(accountId: string, workspaceId: string, nameCiphertext: string) { + await this.assertCapability(accountId, workspaceId, 'workspace.rename') + const [updated] = await this.database.db.update(workspaces).set({ + nameCiphertext, + updatedAt: new Date(), + }).where(and(eq(workspaces.id, workspaceId), isNull(workspaces.deletedAt))).returning({ + id: workspaces.id, + nameCiphertext: workspaces.nameCiphertext, + updatedAt: workspaces.updatedAt, + }) + if (updated === undefined) { + throw new ApiError({ code: 'workspace_not_found', message: 'Workspace not found', statusCode: 404 }) + } + await this.notifier.publish({ type: 'workspace.metadata-changed', workspaceId }).catch(() => undefined) + return updated } async restore(accountId: string, workspaceId: string): Promise { diff --git a/apps/server/test/notegen-client-behavior.integration.test.ts b/apps/server/test/notegen-client-behavior.integration.test.ts index 923fdc6..67881ac 100644 --- a/apps/server/test/notegen-client-behavior.integration.test.ts +++ b/apps/server/test/notegen-client-behavior.integration.test.ts @@ -19,13 +19,16 @@ integration('NoteGen sync protocol durability', () => { const workspace = await api<{ id: string }>(baseUrl, '/v1/workspaces', { method: 'POST', expectedStatus: 201, headers: authorization, body: { - nameCiphertext: 'sync-test-workspace', keyVersion: 1, - envelopes: [{ type: 'passphrase', recipientId: null, wrappedKey: 'test-envelope', - kdfSalt: 'test-salt', kdfParams: { memorySize: 65536, iterations: 3, parallelism: 1 } }, - { type: 'recovery', recipientId: null, wrappedKey: 'test-recovery-envelope', - kdfSalt: null, kdfParams: null }], + nameCiphertext: 'sync-test-workspace', + managedKey: Buffer.alloc(32, 1).toString('base64url'), }, }) + const protocolSession = await api<{ syncEpoch: string }>( + baseUrl, + `/v1/workspaces/${workspace.id}/sync/session?protocolVersion=1&cursor=0`, + { headers: authorization }, + ) + syncEpochs.set(workspace.id, protocolSession.syncEpoch) const objectId = randomUUID() const objectEnvelope = encryptedTestEnvelope('object-v1') const command = { @@ -55,7 +58,7 @@ integration('NoteGen sync protocol durability', () => { const deleteResult = await syncCommands(baseUrl, workspace.id, authorization, [deletion]) expect(deleteResult[0]).toMatchObject({ status: 'conflict', code: 'delete_edit_conflict', conflictId }) const events = await api<{ events: Array<{ type: string }> }>( - baseUrl, `/v1/workspaces/${workspace.id}/sync/events?after=0`, { headers: authorization }, + baseUrl, `/v1/workspaces/${workspace.id}/sync/events?after=0&expectedSyncEpoch=${syncEpoch(workspace.id)}`, { headers: authorization }, ) expect(events.events.map(event => event.type)).toContain('conflict.created') @@ -69,7 +72,7 @@ integration('NoteGen sync protocol durability', () => { const firstBootstrap = await api<{ bootstrapId: string, snapshotSequence: string, objects: Array<{ objectId: string }>, nextObjectId: string | null, hasMore: boolean, - }>(baseUrl, `/v1/workspaces/${workspace.id}/sync/bootstrap?limit=1`, { headers: authorization }) + }>(baseUrl, `/v1/workspaces/${workspace.id}/sync/bootstrap?limit=1&expectedSyncEpoch=${syncEpoch(workspace.id)}`, { headers: authorization }) expect(firstBootstrap.hasMore).toBe(true) const laterObjectId = randomUUID() const laterEnvelope = encryptedTestEnvelope('created-after-bootstrap') @@ -84,7 +87,7 @@ integration('NoteGen sync protocol durability', () => { const page = await api<{ bootstrapId: string, snapshotSequence: string, objects: Array<{ objectId: string }>, nextObjectId: string | null, hasMore: boolean, - }>(baseUrl, `/v1/workspaces/${workspace.id}/sync/bootstrap?limit=1&bootstrapId=${firstBootstrap.bootstrapId}&afterObjectId=${afterObjectId}`, + }>(baseUrl, `/v1/workspaces/${workspace.id}/sync/bootstrap?limit=1&expectedSyncEpoch=${syncEpoch(workspace.id)}&bootstrapId=${firstBootstrap.bootstrapId}&afterObjectId=${afterObjectId}`, { headers: authorization }) expect(page.bootstrapId).toBe(firstBootstrap.bootstrapId) expect(page.snapshotSequence).toBe(firstBootstrap.snapshotSequence) @@ -135,11 +138,65 @@ integration('NoteGen sync protocol durability', () => { status: 'conflict', code: 'delete_edit_conflict', conflictId: subtreeConflictId, }) const afterSubtreeConflict = await api<{ objects: Array<{ objectId: string, deletedAt: string | null }> }>( - baseUrl, `/v1/workspaces/${workspace.id}/sync/bootstrap?limit=1000`, { headers: authorization }, + baseUrl, `/v1/workspaces/${workspace.id}/sync/bootstrap?limit=1000&expectedSyncEpoch=${syncEpoch(workspace.id)}`, { headers: authorization }, ) expect(afterSubtreeConflict.objects.find(item => item.objectId === subtreeRootId)?.deletedAt).toBeNull() expect(afterSubtreeConflict.objects.find(item => item.objectId === subtreeNoteId)?.deletedAt).toBeNull() }, 30_000) + + it('enforces member capabilities, invitation revocation, and removal', async () => { + if (baseUrl === undefined) throw new Error('INTEGRATION_BASE_URL is required') + const owner = await registerIntegrationAccount(baseUrl, 'owner') + const viewer = await registerIntegrationAccount(baseUrl, 'viewer') + const workspace = await api<{ id: string }>(baseUrl, '/v1/workspaces', { + method: 'POST', expectedStatus: 201, headers: owner.headers, + body: { + nameCiphertext: 'shared-library', + managedKey: Buffer.alloc(32, 2).toString('base64url'), + }, + }) + const invitation = await api<{ id: string }>( + baseUrl, `/v1/workspaces/${workspace.id}/invitations/account`, { + method: 'POST', expectedStatus: 201, headers: owner.headers, + body: { login: viewer.login, role: 'viewer' }, + }, + ) + await api(baseUrl, `/v1/workspace-invitations/${invitation.id}/accept`, { + method: 'POST', headers: viewer.headers, + }) + const session = await api<{ syncEpoch: string }>( + baseUrl, `/v1/workspaces/${workspace.id}/sync/session?protocolVersion=1&cursor=0`, + { headers: viewer.headers }, + ) + syncEpochs.set(workspace.id, session.syncEpoch) + const envelope = encryptedTestEnvelope('viewer-write') + const [rejected] = await syncCommands(baseUrl, workspace.id, viewer.headers, [{ + type: 'upsert-object', commandId: randomUUID(), objectId: randomUUID(), kind: 'note', + parentObjectId: null, nameCiphertext: envelope.ciphertext, baseRevision: null, + blobRefs: [], keyVersion: 1, ...envelope, + }]) + expect(rejected).toMatchObject({ status: 'rejected', code: 'workspace_capability_denied' }) + + const link = await api<{ id: string; token: string }>( + baseUrl, `/v1/workspaces/${workspace.id}/invitations/link`, { + method: 'POST', expectedStatus: 201, headers: owner.headers, + body: { role: 'editor' }, + }, + ) + await api(baseUrl, `/v1/workspaces/${workspace.id}/invitations/${link.id}`, { + method: 'DELETE', expectedStatus: 204, headers: owner.headers, + }) + await api(baseUrl, '/v1/workspace-invitations/accept-link', { + method: 'POST', expectedStatus: 404, headers: viewer.headers, body: { token: link.token }, + }) + + await api(baseUrl, `/v1/workspaces/${workspace.id}/members/${viewer.accountId}`, { + method: 'DELETE', expectedStatus: 204, headers: owner.headers, + }) + await api(baseUrl, `/v1/workspaces/${workspace.id}/sync/session?protocolVersion=1&cursor=0`, { + expectedStatus: 404, headers: viewer.headers, + }) + }, 30_000) }) function encryptedTestEnvelope(value: string) { @@ -152,13 +209,38 @@ function encryptedTestEnvelope(value: string) { async function syncCommands(origin: string, workspaceId: string, headers: Record, commands: unknown[]) { const response = await api<{ results: Array> }>( - origin, `/v1/workspaces/${workspaceId}/sync/commands`, { method: 'POST', headers, body: { commands } }, + origin, `/v1/workspaces/${workspaceId}/sync/commands`, { + method: 'POST', headers, body: { commands, expectedSyncEpoch: syncEpoch(workspaceId) }, + }, ) return response.results } +const syncEpochs = new Map() + +function syncEpoch(workspaceId: string) { + const value = syncEpochs.get(workspaceId) + if (!value) throw new Error(`Missing sync epoch for ${workspaceId}`) + return value +} + interface Session { accessToken: string } +async function registerIntegrationAccount(origin: string, label: string) { + const login = `${label}-${randomUUID()}@example.test` + const session = await api(origin, '/v1/auth/register', { + method: 'POST', expectedStatus: 201, + headers: { 'x-setup-token': process.env.INTEGRATION_SETUP_TOKEN ?? 'integration-setup-token' }, + body: { + login, password: 'integration-password', deviceId: randomUUID(), + deviceName: `${label} integration device`, platform: 'test', + }, + }) + const headers = { authorization: `Bearer ${session.accessToken}` } + const account = await api<{ id: string }>(origin, '/v1/account', { headers }) + return { login, accountId: account.id, headers } +} + async function api( origin: string, path: string, @@ -175,5 +257,6 @@ async function api( ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), }) expect(response.status, await response.clone().text()).toBe(options.expectedStatus ?? 200) + if (response.status === 204) return undefined as T return await response.json() as T } diff --git a/apps/web/README.md b/apps/web/README.md index 9e15bf9..c3b108a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -7,7 +7,7 @@ NoteGen Server 的账号管理与设备授权页面。该应用使用 Next.js - 注册和登录同步账号。 - 查看和撤销 NoteGen 设备。 - 确认一次性设备授权。 -- 显示自托管或官方托管服务器身份。 +- 显示当前独立实例身份。 它不包含 Markdown 编辑、AI 或多人协作功能。 diff --git a/apps/web/app/account/accept-invite/page.tsx b/apps/web/app/account/accept-invite/page.tsx new file mode 100644 index 0000000..1af0599 --- /dev/null +++ b/apps/web/app/account/accept-invite/page.tsx @@ -0,0 +1,5 @@ +import { InvitationAcceptance } from "@/components/invitation-acceptance" + +export default function AcceptInvitePage() { + return +} diff --git a/apps/web/app/account/reset-password/page.tsx b/apps/web/app/account/reset-password/page.tsx new file mode 100644 index 0000000..54a0387 --- /dev/null +++ b/apps/web/app/account/reset-password/page.tsx @@ -0,0 +1,5 @@ +import { AccountPortal } from "@/components/account-portal" + +export default function ResetPasswordPage() { + return +} diff --git a/apps/web/app/account/verify-email/page.tsx b/apps/web/app/account/verify-email/page.tsx new file mode 100644 index 0000000..89ef0da --- /dev/null +++ b/apps/web/app/account/verify-email/page.tsx @@ -0,0 +1,5 @@ +import { AccountPortal } from "@/components/account-portal" + +export default function VerifyEmailPage() { + return +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index bfdda76..2f63547 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -127,7 +127,6 @@ body { @apply min-h-svh bg-background text-foreground; - background-image: radial-gradient(circle at top, color-mix(in oklch, var(--primary) 8%, transparent), transparent 32rem); } button:not(:disabled), diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg new file mode 100644 index 0000000..858bb5e --- /dev/null +++ b/apps/web/app/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 56ce8ac..58627c6 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -7,7 +7,7 @@ import { LocaleProvider } from "@/components/locale-provider" import { Toaster } from "@/components/ui/toast" export const metadata: Metadata = { - title: "NoteGen 同步管理后台", + title: "NoteGen Sync", description: "管理 NoteGen 同步内容、工作区与关联设备", } diff --git a/apps/web/app/operations/page.tsx b/apps/web/app/operations/page.tsx deleted file mode 100644 index 55301aa..0000000 --- a/apps/web/app/operations/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { OperationsPortal } from "@/components/operations-portal" - -export default function OperationsPage() { - return -} diff --git a/apps/web/app/service/page.tsx b/apps/web/app/service/page.tsx new file mode 100644 index 0000000..d40b494 --- /dev/null +++ b/apps/web/app/service/page.tsx @@ -0,0 +1,108 @@ +import type { Metadata } from "next" +import Link from "next/link" +import { + DatabaseBackupIcon, + HardDriveIcon, + MailIcon, + ServerIcon, + ShieldCheckIcon, + UserRoundCogIcon, +} from "lucide-react" + +import { NOTEGEN_SITE_URL } from "@/components/notegen-brand" +import { PublicSiteShell } from "@/components/site-chrome" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" + +export const metadata: Metadata = { + title: "服务说明 · NoteGen Sync", + description: "NoteGen 免费独立同步实例的服务范围、数据边界与使用须知", +} + +export default function ServicePage() { + return ( + +
+
+ 免费 · 独立实例 +

服务说明与数据边界

+

+ NoteGen Sync 不区分商业托管版与社区部署版。官方公共测试服务和自行部署实例使用同一套开放源代码、账号体系和管理能力。 +

+
+ + +
+
+ + + + 公共测试服务不是云备份承诺 + + 服务免费提供,不包含商业 SLA。请始终保留本地数据和必要备份;实例可能因维护、资源限制或滥用处理调整开放范围与保留周期。 + + + +
+ + + + +
+ + + + 实例会处理哪些数据 + 实际保留周期由实例配置和管理员维护策略决定。 + + +

服务会保存账号标识、密码哈希、设备与浏览器会话、同步协议元数据、加密后的同步对象与附件,以及保障安全和排查故障所需的有限日志。

+ +

默认托管密钥模式以跨设备零配置为目标,服务器在技术上具备恢复内容密钥的能力;启用高级端到端加密后,服务器只保存加密载荷和密钥封装,用户必须自行保管同步口令或恢复密钥。

+ +

管理员可以停用账号并执行实例提供的删除流程。由于保留期和备份轮换,数据从所有副本中消失可能晚于账号停止使用的时间。

+
+
+ + + + 使用前请确认 + 无论使用公共测试实例还是自行部署,都建议完成以下准备。 + + +
    +
  • 保留 NoteGen 本地数据,不把服务器当作唯一副本。
  • +
  • 为管理员账号设置独立强密码并启用 TOTP。
  • +
  • 自行部署时定期备份数据库和附件存储。
  • +
  • 公开注册前确认存储容量、限流与滥用处理方式。
  • +
+
+
+ + +
+
+ ) +} + +function BoundaryCard({ icon: Icon, title, description }: { + icon: typeof ServerIcon + title: string + description: string +}) { + return ( + + + + {title} + {description} + + + ) +} diff --git a/apps/web/components/account-portal.tsx b/apps/web/components/account-portal.tsx index 7743d20..c73a372 100644 --- a/apps/web/components/account-portal.tsx +++ b/apps/web/components/account-portal.tsx @@ -1,7 +1,8 @@ "use client" -import { useCallback, useEffect, useState, type FormEvent } from "react" +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react" import { + ArrowRightIcon, CircleCheckIcon, HistoryIcon, LaptopIcon, @@ -13,8 +14,11 @@ import { UserRoundCogIcon, UsersIcon, } from "lucide-react" +import QRCode from "react-qr-code" import { AdminShell, type AdminSection } from "@/components/admin-shell" +import { NOTEGEN_SITE_URL } from "@/components/notegen-brand" +import { PublicSiteShell } from "@/components/site-chrome" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -30,6 +34,7 @@ import { CardTitle, } from "@/components/ui/card" import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Input } from "@/components/ui/input" import { Item, @@ -46,8 +51,8 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -import { ThemeToggle } from "@/components/theme-toggle" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { AdminInstanceSummary } from "@/components/admin-instance-summary" import { ExperimentalCenter } from "@/components/experimental-center" import { OperationsCenter } from "@/components/operations-center" @@ -80,8 +85,10 @@ import { formatRelativeTime as formatDate } from "@/lib/relative-time" import { cn } from "@/lib/utils" type AuthMode = "login" | "register" +type AuthStep = "credentials" | "totp" | "forgot" | "reset" | "verify" | "verification-pending" +type AdminDataView = "overview" | "accounts" | "data" | "audit" | "tools" -export function AccountPortal() { +export function AccountPortal({ forceAuthenticationFlow = false }: { forceAuthenticationFlow?: boolean }) { const [, setRelativeTimeTick] = useState(0) const [account, setAccount] = useState(null) const [devices, setDevices] = useState([]) @@ -90,6 +97,7 @@ export function AccountPortal() { const [overview, setOverview] = useState(null) const [workspaces, setWorkspaces] = useState([]) const [deletingWorkspaceId, setDeletingWorkspaceId] = useState(null) + const [recentlyDeletedWorkspace, setRecentlyDeletedWorkspace] = useState(null) const [section, setSection] = useState(readSectionFromUrl) const [adminRefreshVersion, setAdminRefreshVersion] = useState(0) const [adminOverview, setAdminOverview] = useState(null) @@ -109,6 +117,7 @@ export function AccountPortal() { const [adminDeviceOffset, setAdminDeviceOffset] = useState(() => readUrlOffset("devicesOffset")) const [adminAuditOffset, setAdminAuditOffset] = useState(() => readUrlOffset("auditOffset")) const [adminAuditAction, setAdminAuditAction] = useState(() => readUrlParam("auditAction")) + const [adminView, setAdminView] = useState(readAdminViewFromUrl) const [adminLoading, setAdminLoading] = useState(false) const [adminError, setAdminError] = useState("") const [adminBusyAccountId, setAdminBusyAccountId] = useState(null) @@ -128,14 +137,12 @@ export function AccountPortal() { if (overviewResult.status === "fulfilled") setOverview(overviewResult.value) if (workspacesResult.status === "fulfilled") setWorkspaces(workspacesResult.value) - const failed = devicesResult.status === "rejected" - ? devicesResult - : overviewResult.status === "rejected" - ? overviewResult - : workspacesResult.status === "rejected" - ? workspacesResult - : null - if (failed) setError(errorMessage(failed.reason)) + const failures = [ + devicesResult.status === "rejected" ? { label: "设备", reason: devicesResult.reason } : null, + overviewResult.status === "rejected" ? { label: "同步概览", reason: overviewResult.reason } : null, + workspacesResult.status === "rejected" ? { label: "工作区", reason: workspacesResult.reason } : null, + ].filter((item): item is { label: string; reason: unknown } => item !== null) + if (failures.length) setError(`部分数据未更新(${failures.map((item) => item.label).join("、")})。${errorMessage(failures[0].reason)}`) }, []) const loadAdminData = useCallback(async () => { @@ -153,32 +160,38 @@ export function AccountPortal() { const auditQuery = new URLSearchParams(commonQuery) auditQuery.set("offset", String(adminAuditOffset)) if (adminAuditAction) auditQuery.set("action", adminAuditAction) - const [overviewResult, accountsResult, auditResult, workspacesResult, devicesResult, statusResult] = await Promise.allSettled([ - apiRequest("/v1/web/admin/overview"), - apiRequest(`/v1/web/admin/accounts?${accountQuery}`), - apiRequest(`/v1/web/admin/audit?${auditQuery}`), - apiRequest(`/v1/web/admin/workspaces?${workspaceQuery}`), - apiRequest(`/v1/web/admin/devices?${deviceQuery}`), - apiRequest("/v1/web/admin/status"), - ]) - if (overviewResult.status === "fulfilled") setAdminOverview(overviewResult.value) - if (accountsResult.status === "fulfilled") { setAdminAccounts(accountsResult.value.accounts); setAdminAccountTotal(accountsResult.value.total) } - if (auditResult.status === "fulfilled") { setAdminAudit(auditResult.value.entries); setAdminAuditTotal(auditResult.value.total) } - if (workspacesResult.status === "fulfilled") { setAdminWorkspaces(workspacesResult.value.workspaces); setAdminWorkspaceTotal(workspacesResult.value.total) } - if (devicesResult.status === "fulfilled") { setAdminDevices(devicesResult.value.devices); setAdminDeviceTotal(devicesResult.value.total) } - if (statusResult.status === "fulfilled") setAdminStatus(statusResult.value) - const failed = [overviewResult, accountsResult, auditResult, workspacesResult, devicesResult, statusResult] - .find((result) => result.status === "rejected") - if (failed?.status === "rejected") setAdminError(errorMessage(failed.reason)) + if (adminView === "overview") { + const [nextOverview, nextStatus] = await Promise.all([ + apiRequest("/v1/web/admin/overview"), + apiRequest("/v1/web/admin/status"), + ]) + setAdminOverview(nextOverview); setAdminStatus(nextStatus) + } else if (adminView === "accounts") { + const page = await apiRequest(`/v1/web/admin/accounts?${accountQuery}`) + setAdminAccounts(page.accounts); setAdminAccountTotal(page.total) + } else if (adminView === "audit") { + const page = await apiRequest(`/v1/web/admin/audit?${auditQuery}`) + setAdminAudit(page.entries); setAdminAuditTotal(page.total) + } else if (adminView === "data") { + const [workspacePage, devicePage] = await Promise.all([ + apiRequest(`/v1/web/admin/workspaces?${workspaceQuery}`), + apiRequest(`/v1/web/admin/devices?${deviceQuery}`), + ]) + setAdminWorkspaces(workspacePage.workspaces); setAdminWorkspaceTotal(workspacePage.total) + setAdminDevices(devicePage.devices); setAdminDeviceTotal(devicePage.total) + } + } catch (cause) { + setAdminError(errorMessage(cause)) } finally { setAdminLoading(false) } - }, [adminAccountOffset, adminAccountStatus, adminAuditAction, adminAuditOffset, adminDeviceOffset, adminQuery, adminWorkspaceOffset]) + }, [adminAccountOffset, adminAccountStatus, adminAuditAction, adminAuditOffset, adminDeviceOffset, adminQuery, adminView, adminWorkspaceOffset]) const loadAccount = useCallback(async () => { try { const session = await apiRequest<{ account: Account }>("/v1/web/session") setAccount(session.account) + setError("") setLoading(false) void loadDashboardData() redirectAfterAuth() @@ -254,7 +267,7 @@ export function AccountPortal() { }, [account, section]) useEffect(() => { - const onPopState = () => setSection(readSectionFromUrl()) + const onPopState = () => { setSection(readSectionFromUrl()); setAdminView(readAdminViewFromUrl()) } window.addEventListener("popstate", onPopState) return () => window.removeEventListener("popstate", onPopState) }, []) @@ -269,8 +282,9 @@ export function AccountPortal() { setOptionalParam(url, "devicesOffset", adminDeviceOffset ? String(adminDeviceOffset) : "") setOptionalParam(url, "auditOffset", adminAuditOffset ? String(adminAuditOffset) : "") setOptionalParam(url, "auditAction", adminAuditAction) + setOptionalParam(url, "adminView", adminView === "overview" ? "" : adminView) window.history.replaceState(null, "", url) - }, [section, adminQuery, adminAccountStatus, adminAccountOffset, adminWorkspaceOffset, adminDeviceOffset, adminAuditOffset, adminAuditAction]) + }, [section, adminQuery, adminAccountStatus, adminAccountOffset, adminWorkspaceOffset, adminDeviceOffset, adminAuditOffset, adminAuditAction, adminView]) const navigateSection = useCallback((nextSection: AdminSection) => { setSection(nextSection) @@ -336,7 +350,23 @@ export function AccountPortal() { method: "DELETE", csrf: true, }) + setRecentlyDeletedWorkspace(workspaces.find((workspace) => workspace.id === targetWorkspaceId) ?? null) + await loadDashboardData() + } catch (cause) { + setError(errorMessage(cause)) + } finally { + setDeletingWorkspaceId(null) + } + } + + async function handleRestoreWorkspace(targetWorkspaceId: string) { + setDeletingWorkspaceId(targetWorkspaceId) + setError("") + try { + await apiRequest(`/v1/web/workspaces/${targetWorkspaceId}/restore`, { method: "POST", csrf: true }) + setRecentlyDeletedWorkspace(null) await loadDashboardData() + toast.add({ title: "工作区已恢复", type: "success" }) } catch (cause) { setError(errorMessage(cause)) } finally { @@ -380,7 +410,7 @@ export function AccountPortal() { if (loading) { return ( -
+
@@ -397,36 +427,56 @@ export function AccountPortal() { return } - if (!account) { + if (!account || forceAuthenticationFlow) { return ( -
- - {error ? : null} -
- - - NoteGen Sync - 让同步保持清晰、可控。 - - 在这里管理账号、设备和同步空间。敏感操作始终由服务器再次验证,而不只依赖页面状态。 - - - - - - - - - + +
+
+
+
+ + NoteGen Sync · 跨设备同步 + +

+ 连接设备,
保持同步。 +

+

+ 管理 NoteGen 账号、设备和同步空间。官方公共测试服务与自行部署使用相同的开放源代码和运行方式。 +

+
+ + + +
+

+ 还没有 NoteGen? + + 下载客户端 + + +

+
+
+ {error ? : null} + + + + 免费独立实例 + + 本服务不包含付费订阅或商业 SLA。请保留本地数据;实例维护、开放范围和数据保留策略由实例管理员负责。查看服务说明 + + +
+
+
) } @@ -456,15 +506,17 @@ export function AccountPortal() { }} > {error ? : null} - {section === "overview" ? <>{account.isAdmin ? : null} : null} + {section === "overview" ? <>{account.isAdmin ? : null} : null} {section === "instance" && account.isAdmin ? : null} {section === "experiments" && account.isAdmin ? : null} {section === "operations" && account.isAdmin ? : null} {section === "workspaces" ? ( ) : null} {section === "devices" ? ( @@ -472,6 +524,7 @@ export function AccountPortal() { devices={devices} busy={busy} onRevoke={(id) => void handleRevokeDevice(id)} + onConnect={() => navigateSection("connect")} /> ) : null} {section === "connect" ? : null} @@ -489,6 +542,7 @@ export function AccountPortal() { devices={adminDevices} deviceTotal={adminDeviceTotal} status={adminStatus} + adminView={adminView} query={adminQuery} accountStatus={adminAccountStatus} accountOffset={adminAccountOffset} @@ -500,6 +554,7 @@ export function AccountPortal() { error={adminError} busyAccountId={adminBusyAccountId} onRefresh={() => void loadAdminData()} + onAdminViewChange={setAdminView} onSetSuspended={handleSetAccountSuspended} onSetAdmin={handleSetAccountAdmin} onQueryChange={(value) => { @@ -518,40 +573,12 @@ export function AccountPortal() { ) } -function LoginHeader({ capabilities }: { capabilities: ServerCapabilities | null }) { - return ( -
-
- - - -
-

{capabilities?.serverName ?? "NoteGen 同步服务器"}

-

登录同步管理后台

-
-
-
- {capabilities ? ( - <> - - {capabilities.deploymentMode === "hosted" ? "官方托管" : "自托管"} - - - {capabilities.registrationMode === "open" ? "开放注册" : "关闭注册"} - - - ) : null} - -
-
- ) -} - function ErrorAlert({ message }: { message: string }) { + const connectionFailed = message.includes("无法连接同步服务器") return ( - 操作失败 + {connectionFailed ? "暂时无法连接" : "操作失败"} {message} ) @@ -560,31 +587,56 @@ function ErrorAlert({ message }: { message: string }) { function LoginBenefit({ icon: Icon, title, - description, }: { icon: typeof ShieldCheckIcon title: string - description: string }) { return ( -
- - - -
-

{title}

-

{description}

-
+
+ + {title}
) } -function OverviewSection({ overview }: { overview: SyncOverview | null }) { +function OverviewSection({ overview, devices, onNavigate }: { + overview: SyncOverview | null + devices: Device[] + onNavigate: (section: AdminSection) => void +}) { const contentStats = summarizeContentKinds(overview?.kinds ?? []) + const storage = overview?.storageUsage + const activeBytes = storage ? sumByteStrings( + storage.activeObjectBytes, storage.activeCrdtBytes, storage.activeBlobBytes, + ) : "0" + const enforcedBytes = storage ? sumByteStrings( + activeBytes, storage.reservedBlobBytes, storage.retainedBytes, + ) : "0" + const activeDevices = devices.filter((device) => !device.revokedAt) + const behindDevices = activeDevices.filter((device) => device.syncStatus === "behind") + const health = activeDevices.length === 0 + ? { label: "等待连接设备", description: "关联第一台 NoteGen 设备后,服务器会自动建立同步空间。", variant: "outline" as const } + : behindDevices.length > 0 + ? { label: `${behindDevices.length} 台设备尚未追平`, description: "打开对应设备上的 NoteGen,让它保持联网以完成同步。", variant: "destructive" as const } + : { label: "同步正常", description: `${activeDevices.length} 台设备已连接${overview?.lastActivityAt ? `,最近同步于 ${formatDate(overview.lastActivityAt)}` : ",正在等待首次同步"}。`, variant: "secondary" as const } return ( <> - + + +
+ {health.label} +
+ 你的同步状态 + {health.description} +
+
+ +
+
+ 同步概览 汇总当前账号的同步对象、物理存储、序列和加密状态;已移除附件会按保留策略延迟回收。 @@ -613,6 +665,23 @@ function OverviewSection({ overview }: { overview: SyncOverview | null }) { + {storage ? ( + + + 存储核算 + + 服务端按活跃数据、上传预留和历史保留的总和核算安全上限。删除内容后,历史保留空间会按保留策略延迟释放。 + + + + + + + + + + ) : null} + 内容数量 @@ -650,9 +719,7 @@ function OverviewSection({ overview }: { overview: SyncOverview | null }) { ))} - ) : ( -

还没有同步活动。

- )} + ) : onNavigate("connect")} />}
@@ -661,14 +728,22 @@ function OverviewSection({ overview }: { overview: SyncOverview | null }) { ) } +function sumByteStrings(...values: string[]): string { + return values.reduce((total, value) => total + BigInt(value || "0"), 0n).toString() +} + function WorkspaceManagement({ workspaces, + recentlyDeletedWorkspace, deletingWorkspaceId, onDelete, + onRestore, }: { workspaces: WebWorkspace[] + recentlyDeletedWorkspace: WebWorkspace | null deletingWorkspaceId: string | null onDelete: (id: string) => Promise + onRestore: (id: string) => Promise }) { const sorted = [...workspaces].sort((left, right) => ( Number(right.isDefault) - Number(left.isDefault) @@ -684,6 +759,7 @@ function WorkspaceManagement({ 查看工作区状态与用量;默认工作区受到保护,历史工作区可在确认后软删除。 + {recentlyDeletedWorkspace ? 历史工作区已删除工作区 {recentlyDeletedWorkspace.id.slice(0, 8)} 已进入软删除状态,可以立即恢复。 : null} {historicalCount ? ( @@ -719,13 +795,7 @@ function WorkspaceManagement({ ))} - ) : ( - - - 还没有同步工作区 - 连接 NoteGen 并完成首次同步后,工作区会显示在这里。 - - )} + ) : } ) @@ -735,10 +805,12 @@ function DeviceManagement({ devices, busy, onRevoke, + onConnect, }: { devices: Device[] busy: boolean onRevoke: (id: string) => void + onConnect: () => void }) { return ( @@ -755,8 +827,14 @@ function DeviceManagement({ {device.name} - {device.platform} · 最近活动 {formatDate(device.lastSeenAt)} · 设备 ID:{device.id} + {device.platform} · 最近活动 {formatDate(device.lastSeenAt)} · 关联于 {formatDate(device.createdAt)} +
+ + {device.acknowledgedAt ? ( + 最后确认 {formatDate(device.acknowledgedAt)} + ) : null} +
{isLikelyDuplicateDevice(device, devices) ? 疑似重复设备 : null}
@@ -775,14 +853,28 @@ function DeviceManagement({ ))} - ) : ( -

还没有关联 NoteGen 设备。

- )} + ) : }
) } +function DeviceSyncBadge({ device }: { device: Device }) { + if (device.syncStatus === "caught-up") return 已追平 + if (device.syncStatus === "behind") return 落后 {device.pendingEventCount} 项 + return 从未确认同步 +} + +function ActionEmpty({ icon: Icon, title, description, action, onAction }: { + icon: typeof LaptopIcon + title: string + description: string + action?: string + onAction?: () => void +}) { + return {title}{description}{action && onAction ? : null} +} + function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) { const [currentPassword, setCurrentPassword] = useState("") const [newPassword, setNewPassword] = useState("") @@ -792,15 +884,20 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) const [sessions, setSessions] = useState>([]) + const [sessionsLoading, setSessionsLoading] = useState(true) const [totpEnabled, setTotpEnabled] = useState(initiallyEnabled) const [totpPassword, setTotpPassword] = useState("") const [totpCode, setTotpCode] = useState("") const [totpSecret, setTotpSecret] = useState("") + const [totpUri, setTotpUri] = useState("") + const [deletionPassword, setDeletionPassword] = useState("") const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword const loadSessions = useCallback(async () => { + setSessionsLoading(true) try { setSessions(await apiRequest("/v1/web/sessions")) } catch (cause) { setError(errorMessage(cause)) } + finally { setSessionsLoading(false) } }, []) useEffect(() => { void loadSessions() }, [loadSessions]) @@ -822,6 +919,7 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) method: "POST", csrf: true, body: JSON.stringify({ currentPassword: totpPassword }), }) setTotpSecret(setup.secret) + setTotpUri(setup.uri) } catch (cause) { setError(errorMessage(cause)) } finally { setBusy(false) } } @@ -830,7 +928,8 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) setBusy(true); setError("") try { await apiRequest("/v1/web/auth/totp/enable", { method: "POST", csrf: true, body: JSON.stringify({ code: totpCode }) }) - setTotpEnabled(true); setTotpSecret(""); setTotpPassword(""); setTotpCode("") + setTotpEnabled(true); setTotpSecret(""); setTotpUri(""); setTotpPassword(""); setTotpCode("") + toast.add({ title: "双因素认证已启用", description: "下次登录时需要输入验证器验证码。", type: "success" }) } catch (cause) { setError(errorMessage(cause)) } finally { setBusy(false) } } @@ -842,6 +941,7 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) method: "DELETE", csrf: true, body: JSON.stringify({ currentPassword: totpPassword, code: totpCode }), }) setTotpEnabled(false); setTotpPassword(""); setTotpCode("") + toast.add({ title: "双因素认证已关闭", type: "success" }) } catch (cause) { setError(errorMessage(cause)) } finally { setBusy(false) } } @@ -871,15 +971,32 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) } } + async function requestAccountDeletion() { + setBusy(true) + setError("") + try { + await apiRequest("/v1/web/account", { + method: "DELETE", + csrf: true, + body: JSON.stringify({ password: deletionPassword, confirmation: "DELETE" }), + }) + window.location.assign("/") + } catch (cause) { + setError(errorMessage(cause)) + setBusy(false) + } + } + return ( -
+
+ {error ? : null} +
修改密码 修改后会撤销旧设备登录凭据并注销其他浏览器会话,当前浏览器会自动保持登录。 - {error ? : null}
@@ -909,7 +1026,7 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) 浏览器会话检查登录位置,并注销当前浏览器之外的全部会话。 - {sessions.map((session) => ( + {sessionsLoading ?
: sessions.length ? {sessions.map((session) => ( {session.current ? "当前浏览器" : "其他浏览器"} @@ -917,8 +1034,8 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) {session.current ? 当前 : null} - ))} -
@@ -928,17 +1045,44 @@ function PasswordManagement({ initiallyEnabled }: { initiallyEnabled: boolean }) {totpEnabled ? "已启用" : "未启用"} 当前密码 setTotpPassword(event.target.value)} /> - {totpSecret ? 添加到验证器密钥:{totpSecret} : null} + {totpSecret ?
扫描二维码使用验证器应用扫描二维码,然后输入生成的 6 位验证码。{totpSecret}
: null} 6 位验证码 setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} /> {totpEnabled ? ( ) : totpSecret ? ( - +
) : ( )}
+ + 停用并删除账号账号会立即停止登录并撤销设备凭据;数据进入实例配置的保留期,之后由维护任务清理。 + + 此操作会中断所有设备同步请先确认 NoteGen 本地数据完整。最后一个可用管理员不能删除自己的账号。 + + 当前密码 + setDeletionPassword(event.target.value)} /> + 输入密码后仍需在确认窗口中执行操作。 + + + }> + 停用并删除账号 + + + + 确认停用并删除账号? + 所有浏览器和设备会立即退出,服务器数据将在保留期结束后清理。请确认本地数据已经保存。 + + + 取消 + void requestAccountDeletion()}>确认删除 + + + + + +
) } @@ -955,6 +1099,7 @@ function SystemManagement({ devices, deviceTotal, status, + adminView, query, accountStatus, accountOffset, @@ -966,6 +1111,7 @@ function SystemManagement({ error, busyAccountId, onRefresh, + onAdminViewChange, onSetSuspended, onSetAdmin, onQueryChange, @@ -987,6 +1133,7 @@ function SystemManagement({ devices: AdminDevice[] deviceTotal: number status: AdminSystemStatus | null + adminView: AdminDataView query: string accountStatus: string accountOffset: number @@ -998,6 +1145,7 @@ function SystemManagement({ error: string busyAccountId: string | null onRefresh: () => void + onAdminViewChange: (view: AdminDataView) => void onSetSuspended: (accountId: string, suspended: boolean) => Promise onSetAdmin: (accountId: string, isAdmin: boolean) => Promise onQueryChange: (value: string) => void @@ -1015,6 +1163,8 @@ function SystemManagement({ const [webSessions, setWebSessions] = useState([]) const [backups, setBackups] = useState([]) const [storageReport, setStorageReport] = useState(null) + const [queryDraft, setQueryDraft] = useState(query) + useEffect(() => setQueryDraft(query), [query]) async function batchSuspend(suspended: boolean) { setManagementError("") @@ -1078,25 +1228,47 @@ function SystemManagement({ } catch (cause) { setManagementError(errorMessage(cause)) } } + async function exportData(scope: "accounts" | "workspaces" | "devices" | "audit") { + setManagementError("") + try { + await downloadAdminExport(scope) + } catch (cause) { + setManagementError(errorMessage(cause)) + } + } + return ( <> {error ? : null} {managementError ? : null} - + { if (value) onAdminViewChange(value as AdminDataView) }}> + 总览 + 账号 + 同步数据 + 审计 + 运维 + + {adminView !== "overview" && adminView !== "tools" ? 查询与导出 搜索账号、工作区 ID、设备或审计目标;导出文件不包含密码、令牌和解密密钥。 - - onQueryChange(event.target.value)} /> + + { event.preventDefault(); onQueryChange(queryDraft.trim()) }}> + setQueryDraft(event.target.value)} /> + + {query ? : null} + +
{(["accounts", "workspaces", "devices", "audit"] as const).map((scope) => ( - ))} +
-
- + : null} + {adminView === "overview" ?
服务器总览 @@ -1129,10 +1301,10 @@ function SystemManagement({ detail={`${overview?.auditCount ?? 0} 条后台审计记录`} /> - + : null}
- + {adminView === "accounts" ? 账号管理 @@ -1248,9 +1420,9 @@ function SystemManagement({ )} - + : null} - + {adminView === "audit" ? 操作审计 保留最近 100 条后台数据变更,便于确认谁在何时执行了什么操作。 @@ -1291,15 +1463,15 @@ function SystemManagement({ )} - + : null}
-
+ {adminView === "data" ?
-
+
: null} - + {adminView === "tools" ?
运维工具管理浏览器会话、旧备份记录和对象存储一致性。
@@ -1327,9 +1499,9 @@ function SystemManagement({
:

点击“检查”扫描数据库和对象存储。

}
-
+
: null} - + {adminView === "overview" ? 运行与存储状态实时查看数据库响应、进程资源和同步数据规模。 @@ -1337,7 +1509,7 @@ function SystemManagement({ - + : null} ) } @@ -1434,45 +1606,96 @@ async function downloadAdminExport(scope: "accounts" | "workspaces" | "devices" function AuthCard({ busy, registrationMethods, - deploymentMode, setBusy, onAuthenticated, onError, }: { busy: boolean registrationMethods: string[] - deploymentMode: "hosted" | "self-hosted" setBusy: (value: boolean) => void onAuthenticated: (account: Account) => Promise onError: (message: string) => void }) { const [mode, setMode] = useState("login") + const [step, setStep] = useState("credentials") const [login, setLogin] = useState("") const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") const [setupToken, setSetupToken] = useState("") + const [showRecoveryOptions, setShowRecoveryOptions] = useState(false) const [totpCode, setTotpCode] = useState("") const [notice, setNotice] = useState("") + const [cooldownSeconds, setCooldownSeconds] = useState(0) + const verificationAttempted = useRef(false) + const token = readAuthToken() const canRegister = registrationMethods.some((method) => ( method === "setup" || method === "password" || method === "email-password" )) const isAdministratorSetup = registrationMethods.includes("setup") + useEffect(() => setStep(readInitialAuthStep()), []) + useEffect(() => { + if (!token || readInitialAuthStep() !== "verify" || verificationAttempted.current) return + verificationAttempted.current = true + setStep("verify") + void verifyEmailToken() + // The action token is immutable for this route; the ref prevents Strict Mode retries. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]) + useEffect(() => { + if (cooldownSeconds <= 0) return + const timer = window.setTimeout(() => setCooldownSeconds((current) => Math.max(0, current - 1)), 1_000) + return () => window.clearTimeout(timer) + }, [cooldownSeconds]) + async function handleSubmit(event: FormEvent) { event.preventDefault() setBusy(true) onError("") setNotice("") try { - if (mode === "register" && deploymentMode === "hosted") { - await apiRequest("/v1/web/auth/register/email", { + if (step === "forgot") { + await apiRequest("/v1/web/auth/password-reset/request", { method: "POST", - body: JSON.stringify({ email: login, password }), + body: JSON.stringify({ email: login }), }) - setPassword("") - setNotice("验证邮件已进入内部测试投递流程。验证完成后即可登录。") + setNotice("如果该邮箱已注册,重置链接会发送到邮箱。请检查收件箱和垃圾邮件。") + setCooldownSeconds(60) + return + } + if (step === "verify") { + await verifyEmailToken() + return + } + if (step === "reset") { + if (password !== confirmPassword) { + onError("两次输入的新密码不一致。") + return + } + await apiRequest("/v1/web/auth/password-reset/complete", { + method: "POST", + body: JSON.stringify({ token, newPassword: password }), + }) + const result = await apiRequest<{ account: Account }>("/v1/web/session") + replaceAuthUrl() + await onAuthenticated(result.account) + window.location.assign("/") return } - if (mode === "register" && deploymentMode === "self-hosted" && isAdministratorSetup) { + if (step === "verification-pending") { + await apiRequest("/v1/web/auth/email/resend", { + method: "POST", + body: JSON.stringify({ email: login }), + }) + setNotice("验证邮件已重新发送,请检查收件箱和垃圾邮件。") + setCooldownSeconds(60) + return + } + if (mode === "register" && password !== confirmPassword) { + onError("两次输入的密码不一致。") + return + } + if (mode === "register" && isAdministratorSetup) { const result = await apiRequest<{ account: Account }>("/v1/setup/complete", { method: "POST", body: JSON.stringify({ login, password, token: setupToken.trim() }), @@ -1484,7 +1707,7 @@ function AuthCard({ } const body = mode === "register" ? { login, password, ...(setupToken.trim() ? { setupToken: setupToken.trim() } : {}) } - : { login, password, ...(totpCode.trim() ? { totpCode: totpCode.trim() } : {}) } + : { login, password, ...(step === "totp" ? { totpCode: totpCode.trim() } : {}) } const result = await apiRequest<{ account: Account }>(`/v1/web/auth/${mode}`, { method: "POST", body: JSON.stringify(body), @@ -1493,6 +1716,38 @@ function AuthCard({ setSetupToken("") setTotpCode("") await onAuthenticated(result.account) + } catch (cause) { + if (isApiRequestError(cause) && cause.code === "totp_required" && step !== "totp") { + setStep("totp") + setTotpCode("") + onError("") + return + } + if (isApiRequestError(cause) && cause.code === "email_verification_required") { + setStep("verification-pending") + onError("") + setNotice("登录前需要先验证邮箱。") + return + } + onError(errorMessage(cause)) + } finally { + setBusy(false) + } + } + + async function verifyEmailToken() { + setBusy(true) + onError("") + setNotice("") + try { + await apiRequest("/v1/web/auth/email/verify", { + method: "POST", + body: JSON.stringify({ token }), + }) + setStep("credentials") + setMode("login") + setNotice("邮箱验证成功,现在可以登录。") + replaceAuthUrl() } catch (cause) { onError(errorMessage(cause)) } finally { @@ -1501,31 +1756,35 @@ function AuthCard({ } return ( - + - 连接你的同步账号 + {authStepTitle(step)} - {deploymentMode === "hosted" && !canRegister - ? "当前官方托管测试实例暂未开放注册。" + {step === "totp" ? `输入 ${login} 的验证器验证码。` + : step === "forgot" ? "输入注册邮箱,我们会发送一个限时重置链接。" + : step === "reset" ? "设置新密码后会自动登录,并使旧登录凭据失效。" + : step === "verify" ? busy ? "正在自动验证邮箱,请稍候。" : "自动验证未完成,你可以重新验证或返回登录。" + : step === "verification-pending" ? `验证邮件已发送至 ${login || "你的邮箱"}。` : "同一账号可连接多个 NoteGen 设备。账号密码不会用于解密端到端加密工作区。"} - setMode(value as AuthMode)}> - + {step === "credentials" ? { setMode(value as AuthMode); setConfirmPassword(""); setNotice(""); onError("") }}> + {canRegister ? 登录 - {canRegister ? 注册 : null} - + 注册 + : null} -
+ - {deploymentMode === "hosted" ? "邮箱" : "账号"} + 账号 setLogin(event.target.value)} autoComplete="username" + maxLength={200} required /> @@ -1538,13 +1797,23 @@ function AuthCard({ onChange={(event) => setPassword(event.target.value)} autoComplete={mode === "login" ? "current-password" : "new-password"} minLength={8} + maxLength={1024} required /> 至少 8 个字符,仅用于账号登录;默认同步无需另设加密口令。 - {mode === "register" && deploymentMode === "self-hosted" && isAdministratorSetup ? ( + {mode === "register" ? 0 && password !== confirmPassword || undefined}> + 确认密码 + setConfirmPassword(event.target.value)} autoComplete="new-password" minLength={8} maxLength={1024} aria-invalid={confirmPassword.length > 0 && password !== confirmPassword || undefined} required /> + {confirmPassword.length > 0 && password !== confirmPassword ? 两次输入的密码不一致。 : null} + : null} + {mode === "register" && isAdministratorSetup && !showRecoveryOptions ? + + 仅灾备恢复时需要,正常创建管理员无需填写。 + : null} + {mode === "register" && isAdministratorSetup && showRecoveryOptions ? ( - 一次性恢复凭据 + 一次性恢复凭据(可选) setSetupToken(event.target.value)} autoComplete="off" /> - 灾备恢复场景请填写本机 CLI 签发的一次性初始化凭据。 - - ) : null} - {mode === "login" ? ( - - 双因素验证码(可选) - setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} /> - 账号启用双因素认证后填写验证器中的 6 位数字。 + 仅灾备恢复场景填写本机 CLI 签发的一次性初始化凭据。 ) : null} {notice ? ( @@ -1570,7 +1832,7 @@ function AuthCard({ ) : null} - @@ -1578,15 +1840,53 @@ function AuthCard({
-
+ : ( +
+ + {step === "totp" ? 6 位验证码 setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} required />请输入验证器当前显示的数字,验证码每 30 秒更新一次。 : null} + {step === "forgot" ? 邮箱 setLogin(event.target.value)} required /> : null} + {step === "reset" ? <>新密码 setPassword(event.target.value)} required />至少 8 个字符。 0 && password !== confirmPassword || undefined}>确认新密码 setConfirmPassword(event.target.value)} aria-invalid={confirmPassword.length > 0 && password !== confirmPassword || undefined} required />{confirmPassword.length > 0 && password !== confirmPassword ? 两次输入的新密码不一致。 : null} : null} + {step === "verify" && !token ? 验证链接不完整请重新打开邮件中的完整链接。 : null} + {notice ? 操作已提交{notice} : null} +
+
+
+ )}
) } +function authStepTitle(step: AuthStep): string { + return ({ credentials: "连接你的同步账号", totp: "双因素验证", forgot: "找回密码", reset: "设置新密码", verify: "验证邮箱", "verification-pending": "检查你的邮箱" })[step] +} + +function readInitialAuthStep(): AuthStep { + if (typeof window === "undefined") return "credentials" + if (window.location.pathname.startsWith("/account/verify-email")) return "verify" + if (window.location.pathname.startsWith("/account/reset-password")) return "reset" + return "credentials" +} + +function readAuthToken(): string { + return typeof window === "undefined" ? "" : new URLSearchParams(window.location.search).get("token") ?? "" +} + +function replaceAuthUrl() { + window.history.replaceState(null, "", "/") +} + function redirectAfterAuth() { const next = new URLSearchParams(window.location.search).get("next") - if (next?.startsWith("/") && !next.startsWith("//")) window.location.assign(next) + if (!next) return + try { + const target = new URL(next, window.location.origin) + if (target.origin === window.location.origin && target.pathname.startsWith("/")) { + window.location.assign(`${target.pathname}${target.search}${target.hash}`) + } + } catch { + // Invalid redirect targets are ignored and the account portal remains open. + } } const adminSections = new Set([ @@ -1616,6 +1916,11 @@ function readUrlOffset(name: string): number { return Number.isSafeInteger(value) && value >= 0 ? value : 0 } +function readAdminViewFromUrl(): AdminDataView { + const value = readUrlParam("adminView") + return value === "accounts" || value === "data" || value === "audit" || value === "tools" ? value : "overview" +} + function setOptionalParam(url: URL, name: string, value: string): void { if (value) url.searchParams.set(name, value) else url.searchParams.delete(name) @@ -1623,7 +1928,7 @@ function setOptionalParam(url: URL, name: string, value: string): void { function Metric({ label, value, detail }: { label: string; value: string; detail: string }) { return ( -
+

{label}

{value}

{detail}

@@ -1706,6 +2011,7 @@ function auditActionLabel(action: string): string { "test-object.create": "创建测试内容", "object.delete": "删除同步内容", "workspace.delete": "删除历史工作区", + "workspace.restore": "恢复历史工作区", "workspace.admin-delete": "管理员删除工作区", "device.revoke": "撤销设备授权", "device.admin-revoke": "管理员撤销设备", diff --git a/apps/web/components/admin-shell.tsx b/apps/web/components/admin-shell.tsx index 43a60a9..b0480bc 100644 --- a/apps/web/components/admin-shell.tsx +++ b/apps/web/components/admin-shell.tsx @@ -2,20 +2,21 @@ import type { ReactNode } from "react" import { + BookOpenIcon, DatabaseIcon, + ExternalLinkIcon, FlaskConicalIcon, GaugeIcon, LayoutDashboardIcon, - LaptopIcon, + LinkIcon, KeyRoundIcon, + MonitorSmartphoneIcon, MailIcon, LogOutIcon, RefreshCwIcon, - ServerIcon, UsersIcon, } from "lucide-react" -import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -26,7 +27,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Separator } from "@/components/ui/separator" import { Sidebar, SidebarContent, @@ -46,7 +46,15 @@ import { } from "@/components/ui/sidebar" import { Spinner } from "@/components/ui/spinner" import { ThemeToggle } from "@/components/theme-toggle" +import { + NOTEGEN_DOCS_URL, + NOTEGEN_SITE_URL, + GitHubMark, + NoteGenMark, + NoteGenWordmark, +} from "@/components/notegen-brand" import type { Account, ServerCapabilities } from "@/lib/api" +import { SiteFooter } from "@/components/site-chrome" export type AdminSection = "overview" | "workspaces" | "devices" | "connect" | "security" | "instance" | "operations" | "admin" | "experiments" @@ -89,8 +97,8 @@ export function AdminShell({ type NavigationItem = { section: AdminSection; label: string; icon: typeof LayoutDashboardIcon; count?: number } const personalNavigation: NavigationItem[] = [ { section: "overview" as const, label: "仪表盘", icon: LayoutDashboardIcon }, - { section: "connect" as const, label: "关联新设备", icon: LaptopIcon }, - { section: "devices" as const, label: "设备管理", icon: LaptopIcon, count: deviceCount }, + { section: "connect" as const, label: "关联新设备", icon: LinkIcon }, + { section: "devices" as const, label: "设备管理", icon: MonitorSmartphoneIcon, count: deviceCount }, { section: "workspaces" as const, label: "工作区管理", icon: DatabaseIcon, count: workspaceCount }, { section: "security" as const, label: "账户安全", icon: KeyRoundIcon }, ] @@ -130,14 +138,19 @@ export function AdminShell({ - - - - + } + > + - {capabilities?.serverName ?? "NoteGen"} - 同步管理后台 + + + {capabilities?.serverName ?? "同步服务"} + + @@ -175,7 +188,7 @@ export function AdminShell({ 账户安全 onSectionChange("connect")}> - + 关联新设备 @@ -194,26 +207,46 @@ export function AdminShell({ - -
+ +
-

{details.title}

+

{details.title}

{details.description}

-
- {capabilities ? ( - <> - - {capabilities.deploymentMode === "hosted" ? "官方托管" : "自托管"} - - - {capabilities.registrationMode === "open" ? "开放注册" : "关闭注册"} - - - ) : null} +
+ + +
- -
{children}
+
{children}
+
) diff --git a/apps/web/components/device-connection.tsx b/apps/web/components/device-connection.tsx index de76b38..865d5f1 100644 --- a/apps/web/components/device-connection.tsx +++ b/apps/web/components/device-connection.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import Link from "next/link" import { CheckCircle2Icon, CopyIcon, ExternalLinkIcon, LaptopIcon, QrCodeIcon, RefreshCwIcon, ShieldCheckIcon, Trash2Icon, XCircleIcon } from "lucide-react" import QRCode from "react-qr-code" @@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Spinner } from "@/components/ui/spinner" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ThemeToggle } from "@/components/theme-toggle" import { apiRequest, isApiRequestError, userFacingErrorMessage, type DeviceAuthorization } from "@/lib/api" @@ -43,10 +44,13 @@ export function DeviceConnection({ const [serverAddress, setServerAddress] = useState("") const [serverAddressError, setServerAddressError] = useState("") const [serverAddressCopied, setServerAddressCopied] = useState(false) + const [method, setMethod] = useState<"code" | "qr">("code") + const authorizationRequestId = useRef(0) + const activePairing = useRef<{ id: string, status: DevicePairingStatus | null } | null>(null) useEffect(() => { const initialCode = new URLSearchParams(window.location.search).get("code") ?? "" - setCode(initialCode) + setCode(formatDeviceCode(initialCode)) if (initialCode) { const url = new URL(window.location.href) url.pathname = "/connect/" @@ -63,13 +67,24 @@ export function DeviceConnection({ }) .catch((cause) => { if (isApiRequestError(cause) && cause.status === 401) { - redirectToLogin() + setSignedIn(false) return } setError(errorMessage(cause)) }) }, []) + useEffect(() => { + activePairing.current = pairing ? { id: pairing.id, status: pairingStatus } : null + }, [pairing, pairingStatus]) + + useEffect(() => () => { + const current = activePairing.current + if (current?.status === "pending") { + void apiRequest(`/v1/web/device-pairings/${current.id}`, { method: "DELETE", csrf: true }).catch(() => undefined) + } + }, []) + useEffect(() => { if (!pairing || pairingStatus !== "pending") return const updateRemaining = () => { @@ -83,7 +98,12 @@ export function DeviceConnection({ void apiRequest<{ status: DevicePairingStatus, expiresAt: string }>( `/v1/web/device-pairings/${pairing.id}` ).then((result) => setPairingStatus(result.status)).catch((cause) => { - if (!(isApiRequestError(cause) && cause.status === 404)) setPairingError(errorMessage(cause)) + if (isApiRequestError(cause) && cause.status === 404) { + setPairingStatus("expired") + setPairingError("") + return + } + setPairingError(errorMessage(cause)) }) }, 2_000) return () => { @@ -148,23 +168,53 @@ export function DeviceConnection({ } } + async function changeMethod(nextMethod: "code" | "qr") { + if (nextMethod === method) return + if (nextMethod === "qr") { + setMethod("qr") + if (!pairing || pairingStatus !== "pending") void createPairing() + return + } + if (pairing?.id && pairingStatus === "pending") { + setPairingBusy(true) + setPairingError("") + try { + await apiRequest(`/v1/web/device-pairings/${pairing.id}`, { method: "DELETE", csrf: true }) + } catch (cause) { + if (!(isApiRequestError(cause) && cause.status === 404)) { + setPairingError(errorMessage(cause)) + setPairingBusy(false) + return + } + } + setPairingBusy(false) + } + setPairing(null) + setPairingStatus(null) + setMethod("code") + } + async function loadAuthorization(value = code) { + const requestId = authorizationRequestId.current + 1 + authorizationRequestId.current = requestId setBusy(true) setError("") try { const result = await apiRequest( `/v1/web/device-authorizations/${encodeURIComponent(value)}` ) + if (requestId !== authorizationRequestId.current) return setAuthorization(result) if (result.status === "approved" || result.status === "denied") { setCompleted(result.status) removeAuthorizationCodeFromUrl() } } catch (cause) { + if (requestId !== authorizationRequestId.current) return setAuthorization(null) setError(errorMessage(cause)) } finally { - setBusy(false) + if (requestId === authorizationRequestId.current) setBusy(false) } } @@ -174,6 +224,7 @@ export function DeviceConnection({ try { await navigator.clipboard.writeText(serverAddress) setServerAddressCopied(true) + window.setTimeout(() => setServerAddressCopied(false), 2_000) } catch { setServerAddressError("复制失败,请手动选择并复制服务器地址。") } @@ -213,7 +264,9 @@ export function DeviceConnection({
)} -
+ {signedIn ? { if (value) void changeMethod(value as "code" | "qr") }}>输入验证码手机扫码 : null} +
+ {method === "code" ? ( 关联 NoteGen 设备 @@ -244,12 +297,7 @@ export function DeviceConnection({ {completed === "approved" ? "设备授权已确认,可以直接打开 NoteGen 完成关联。" : "这次授权不会签发任何设备会话。"} - {completed === "approved" ? ( - - ) : null} + {completed === "approved" ? 请返回刚才显示验证码的 NoteGen,客户端会自动继续连接。 : null} ) : ( @@ -291,9 +339,12 @@ export function DeviceConnection({ id="device-code" value={code} onChange={(event) => { - setCode(event.target.value.toUpperCase()) + const nextCode = formatDeviceCode(event.target.value) + authorizationRequestId.current += 1 + setCode(nextCode) setAuthorization(null) setCompleted(null) + if (signedIn && nextCode.length === 9) void loadAuthorization(nextCode) }} placeholder="ABCD-EFGH" autoCapitalize="characters" @@ -302,7 +353,7 @@ export function DeviceConnection({ 验证码由 NoteGen 客户端显示,5 分钟内有效且只能使用一次。 {signedIn && !authorization ? ( - @@ -349,7 +400,8 @@ export function DeviceConnection({ ) : null} - {signedIn ? ( + ) : null} + {signedIn && method === "qr" ? ( @@ -384,7 +436,7 @@ export function DeviceConnection({ 二维码已过期 - 请刷新二维码后再扫描。 + 二维码可能已过期或被撤销,请重新生成后再扫描。 ) : ( <> @@ -392,6 +444,7 @@ export function DeviceConnection({
剩余 {formatRemainingTime(remainingSeconds)} + {remainingSeconds === 60 ? "二维码还有一分钟过期" : remainingSeconds === 10 ? "二维码还有十秒过期" : ""}

二维码包含一次性配对凭据。扫码成功或倒计时结束后会自动失效。

@@ -429,6 +482,11 @@ export function DeviceConnection({ ) } +function formatDeviceCode(value: string): string { + const normalized = value.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 8) + return normalized.length > 4 ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : normalized +} + const errorMessage = userFacingErrorMessage function formatRemainingTime(totalSeconds: number): string { @@ -437,13 +495,6 @@ function formatRemainingTime(totalSeconds: number): string { return `${minutes}:${seconds.toString().padStart(2, "0")}` } -function redirectToLogin(): void { - const returnPath = `${window.location.pathname}${window.location.search}` - const loginUrl = new URL("/", window.location.origin) - loginUrl.searchParams.set("next", returnPath) - window.location.replace(loginUrl.toString()) -} - function removeAuthorizationCodeFromUrl(): void { const url = new URL(window.location.href) url.searchParams.delete("code") diff --git a/apps/web/components/experimental-center.tsx b/apps/web/components/experimental-center.tsx index 2c875f7..86533de 100644 --- a/apps/web/components/experimental-center.tsx +++ b/apps/web/components/experimental-center.tsx @@ -28,7 +28,7 @@ export function ExperimentalCenter({ refreshVersion = 0 }: { refreshVersion?: nu return
实验入口不会绕过服务端安全门槛这里保留尚未稳定开放的功能及其启用条件。不可用项目只展示状态,不会调用对应写接口。 - 实验能力{data?.deploymentMode === "self-hosted" ? "当前为自托管实例;托管专属能力会明确标记为不可用。" : "当前为官方托管实例。"} + 实验能力当前为独立实例;实验能力只有满足服务端安全条件后才可启用。 {rows.map((item) => )}
@@ -43,11 +43,6 @@ function capabilityTitle(id: string): string { "identity.email": "邮箱身份", "identity.emailVerification": "邮箱验证", "identity.passwordReset": "密码找回", - "risk.advanced": "高级风控", - "usage.enforcement": "用量强制限制", - "billing.subscription": "订阅与权益", - "compliance.requests": "合规数据请求", - "support.cases": "客服工单", "operations.unifiedBackup": "统一备份", "operations.upgradeAssistant": "升级助手", "operations.preserveRestore": "原实例恢复", diff --git a/apps/web/components/installation-guide.tsx b/apps/web/components/installation-guide.tsx index 191b664..ddc207c 100644 --- a/apps/web/components/installation-guide.tsx +++ b/apps/web/components/installation-guide.tsx @@ -1,226 +1,125 @@ "use client" -import { useEffect, useState, type FormEvent, type ReactNode } from "react" -import { - ArrowLeftIcon, - ArrowRightIcon, - Building2Icon, - CheckIcon, - Globe2Icon, - ServerIcon, - Settings2Icon, - ShieldCheckIcon, - UserRoundCogIcon, -} from "lucide-react" +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react" +import { ShieldCheckIcon, UserRoundCogIcon } from "lucide-react" -import { LanguageToggle } from "@/components/language-toggle" import { useLocale, type Locale } from "@/components/locale-provider" -import { ThemeToggle } from "@/components/theme-toggle" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item" -import { Separator } from "@/components/ui/separator" import { Spinner } from "@/components/ui/spinner" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { apiRequest, isApiRequestError } from "@/lib/api" export interface InstallationStatus { installationRequired: boolean activationPending: boolean - deploymentMode: "self-hosted" | "hosted" | null serverName: string } -type DeploymentMode = "self-hosted" | "hosted" -type RegistrationPolicy = "disabled" | "public" - const COPY = { "zh-CN": { - product: "NoteGen 同步服务器", - guide: "Web 安装向导", firstInstall: "首次安装", - steps: ["部署模式", "实例配置", "确认安装"], - stepDescriptions: ["确定服务运行方式", "填写实例初始信息", "核对并写入数据库"], - modeTitle: "选择部署模式", - modeDescription: "部署模式决定账号体系、管理权限和可用服务。", - modeLabel: "使用方式", - selfHosted: "自托管", - selfHostedDescription: "适合个人、家庭或社区,自主管理账号与数据。", - hosted: "运营模式", - hostedDescription: "适合平台运营,目前使用安全的内部测试适配器。", - immutableMode: "初始化完成后,当前数据库不能直接切换部署模式。", - configurationTitle: "配置实例信息", - selfHostedConfigurationDescription: "设置服务名称并创建首位管理员。", - hostedConfigurationDescription: "设置服务名称、创建首位运营管理员,并选择客户注册策略。", - serverName: "服务名称", - serverNameDescription: "显示在登录页和客户端连接信息中,之后可在后台修改。", + title: "创建管理员账号", + description: "这个账号将拥有实例管理权限。登录后可以继续配置注册、邮件、存储限制和数据保留策略。", administratorLogin: "管理员账号", administratorPassword: "管理员密码", - passwordDescription: "至少 8 个字符。安装完成后使用此账号登录 Web 管理后台。", - operationsAdministratorLogin: "运营管理员账号", - operationsAdministratorPassword: "运营管理员密码", - operationsPasswordDescription: "至少 8 个字符。此账号只用于运营后台,不是客户账号。", - registration: "新用户注册", - disabled: "关闭", - public: "公开", - registrationDescription: "运营人员使用独立的 Staff 权限域,不会成为客户账号管理员。", - internalTitle: "内部测试配置", - internalDescription: "计费使用 Mock,邮件写入脱敏日志,不会真实扣款或投递邮件。", - reviewTitle: "确认安装配置", - reviewDescription: "请检查下面的信息。提交后部署模式将固化到数据库。", - reviewMode: "部署模式", - reviewServer: "服务名称", - reviewAdministrator: "初始管理员", - reviewOperationsAdministrator: "首位运营管理员", - reviewRegistration: "注册策略", - beforePublicTitle: "请先安装,再开放公网服务", - beforePublicDescription: "空实例允许首位访问者完成初始化。完成本向导后,再配置公网反向代理或开放端口。", - back: "上一步", - continue: "继续", - install: "保存并完成初始化", + passwordDescription: "至少 8 个字符,请使用独立密码。", + confirmPassword: "确认密码", + passwordMismatch: "两次输入的密码不一致。", + securityTitle: "完成初始化后再开放公网", + securityDescription: "空实例允许首位访问者创建管理员。请先完成此步骤,再配置公开反向代理或开放端口。", + install: "创建管理员并启动", installing: "正在初始化", failed: "安装失败", - saved: "安装配置已保存", - activatingTitle: "正在启动", - activatingDescription: (mode: DeploymentMode) => `正在启用${mode === "hosted" ? "运营模式" : "自托管模式"}。`, + saved: "安装设置已保存", + activatingTitle: "正在启动实例", + activatingDescription: "实例正在加载配置,完成后会自动进入登录页。", }, en: { - product: "NoteGen Sync Server", - guide: "Web installation guide", - firstInstall: "First-time setup", - steps: ["Deployment", "Instance setup", "Review"], - stepDescriptions: ["Select how the service runs", "Enter the initial settings", "Review and save to the database"], - modeTitle: "Choose a deployment mode", - modeDescription: "The deployment mode determines the account model, administrative permissions, and available services.", - modeLabel: "Deployment type", - selfHosted: "Self-hosted", - selfHostedDescription: "For individuals, families, or communities that manage their own accounts and data.", - hosted: "Operations mode", - hostedDescription: "For a managed platform. Safe internal-test adapters are currently used.", - immutableMode: "The deployment mode cannot be changed in this database after installation.", - configurationTitle: "Configure the instance", - selfHostedConfigurationDescription: "Name the service and create the first administrator.", - hostedConfigurationDescription: "Name the service, create the first operations administrator, and choose the customer registration policy.", - serverName: "Service name", - serverNameDescription: "Shown on the sign-in page and in client connection details. It can be changed later.", + firstInstall: "First installation", + title: "Create an administrator account", + description: "This account will have full instance administration access. Registration, email, storage limits, and retention policies can be configured after signing in.", administratorLogin: "Administrator account", administratorPassword: "Administrator password", - passwordDescription: "Use at least 8 characters. This account signs in to the Web administration portal.", - operationsAdministratorLogin: "Operations administrator account", - operationsAdministratorPassword: "Operations administrator password", - operationsPasswordDescription: "Use at least 8 characters. This account is only for the operations portal and is not a customer account.", - registration: "New user registration", - disabled: "Disabled", - public: "Public", - registrationDescription: "Operators use a separate Staff permission realm and are not customer account administrators.", - internalTitle: "Internal-test configuration", - internalDescription: "Billing uses a mock provider and mail goes to a redacted log. No real charge or delivery occurs.", - reviewTitle: "Review installation", - reviewDescription: "Check the settings below. Submitting will make the deployment mode permanent for this database.", - reviewMode: "Deployment mode", - reviewServer: "Service name", - reviewAdministrator: "Initial administrator", - reviewOperationsAdministrator: "First operations administrator", - reviewRegistration: "Registration policy", - beforePublicTitle: "Install before exposing the service", - beforePublicDescription: "The first visitor to an empty instance can initialize it. Finish this guide before configuring a public reverse proxy or opening the port.", - back: "Back", - continue: "Continue", - install: "Save and finish installation", - installing: "Installing", + passwordDescription: "Use at least 8 characters and a password unique to this service.", + confirmPassword: "Confirm password", + passwordMismatch: "The passwords do not match.", + securityTitle: "Finish initialization before exposing the server", + securityDescription: "The first visitor to an empty instance can create its administrator. Complete this page before configuring a public reverse proxy or opening the port.", + install: "Create administrator and start", + installing: "Initializing", failed: "Installation failed", saved: "Installation settings saved", - activatingTitle: "Starting", - activatingDescription: (mode: DeploymentMode) => `${mode === "hosted" ? "Operations mode" : "Self-hosted mode"} is being enabled.`, + activatingTitle: "Starting the instance", + activatingDescription: "The instance is loading its configuration. You will be redirected to sign in when it is ready.", }, } as const -export function InstallationGuide({ - initialStatus, - onStatusChange, -}: { +export function InstallationGuide({ initialStatus, onStatusChange }: { initialStatus: InstallationStatus onStatusChange: (status: InstallationStatus) => void }) { const { locale } = useLocale() const c = COPY[locale] - const [step, setStep] = useState(0) - const [deploymentMode, setDeploymentMode] = useState("self-hosted") - const [registrationPolicy, setRegistrationPolicy] = useState("disabled") - const [serverName, setServerName] = useState(initialStatus.serverName) const [login, setLogin] = useState("") const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") const [busy, setBusy] = useState(false) const [error, setError] = useState("") + const [activationWaitSeconds, setActivationWaitSeconds] = useState(0) + const activationCheckBusy = useRef(false) useEffect(() => { if (!initialStatus.activationPending) return let active = true - const installedMode = initialStatus.deploymentMode ?? "self-hosted" const checkReady = async () => { + if (activationCheckBusy.current) return + activationCheckBusy.current = true try { const status = await apiRequest("/v1/installation/status") if (active && !status.activationPending) { - window.location.assign(installedMode === "hosted" ? "/operations/" : "/") + if (status.installationRequired) onStatusChange(status) + else window.location.assign("/") } } catch { // The API is briefly unavailable while it replaces the installation app. + } finally { + activationCheckBusy.current = false } } void checkReady() - const timer = window.setInterval(() => void checkReady(), 500) + const timer = window.setInterval(() => { + setActivationWaitSeconds((current) => current + 1) + void checkReady() + }, 1_000) return () => { active = false window.clearInterval(timer) } - }, [initialStatus.activationPending, initialStatus.deploymentMode]) + }, [initialStatus.activationPending, onStatusChange]) - const canContinue = step !== 1 || ( - serverName.trim().length > 0 - && login.trim().length > 0 - && password.length >= 8 - ) + const passwordMismatch = confirmPassword.length > 0 && password !== confirmPassword + const canSubmit = login.trim().length > 0 && password.length >= 8 && password === confirmPassword async function handleSubmit(event: FormEvent) { event.preventDefault() - if (step < 2) { - setStep((current) => Math.min(2, current + 1)) - return - } + if (!canSubmit) return setBusy(true) setError("") try { - const result = await apiRequest<{ - deploymentMode: DeploymentMode - serverName: string - activationPending: true - }>("/v1/installation/complete", { + const result = await apiRequest<{ serverName: string, activationPending: true }>("/v1/installation/complete", { method: "POST", body: JSON.stringify({ - deploymentMode, - serverName, - administrator: { login, password }, - ...(deploymentMode === "hosted" ? { hostedRegistrationPolicy: registrationPolicy } : {}), + serverName: initialStatus.serverName, + administrator: { login: login.trim(), password }, }), }) setPassword("") - onStatusChange({ - installationRequired: false, - activationPending: result.activationPending, - deploymentMode: result.deploymentMode, - serverName: result.serverName, - }) + setConfirmPassword("") + onStatusChange({ installationRequired: false, activationPending: result.activationPending, serverName: result.serverName }) } catch (cause) { setError(installationErrorMessage(cause, locale)) } finally { @@ -229,15 +128,26 @@ export function InstallationGuide({ } if (initialStatus.activationPending) { - const installedMode = initialStatus.deploymentMode ?? "self-hosted" return ( - + {c.saved} {c.activatingTitle} - {c.activatingDescription(installedMode)} + {c.activatingDescription} + {activationWaitSeconds >= 30 ? ( + + + + {locale === "zh-CN" ? "启动时间比预期更长" : "Startup is taking longer than expected"} + + {locale === "zh-CN" ? "配置已经保存。请确认服务进程正在运行;也可以重新检查当前状态。" : "Your settings are saved. Confirm that the service process is running, or check the current status again."} + + + + + ) : null} ) @@ -245,162 +155,50 @@ export function InstallationGuide({ return ( - + - {c.firstInstall} - {c.steps[step]} - {c.stepDescriptions[step]} +
+ +
+ {c.firstInstall} + {c.title} + {c.description}
-
- - -
- {step === 0 ? ( - - - - {c.modeLabel} - { - const value = values[0] - if (value === "self-hosted" || value === "hosted") setDeploymentMode(value) - }} - > - - - - {c.selfHosted} - {c.selfHostedDescription} - - - - - - {c.hosted} - {c.hostedDescription} - - - - {c.immutableMode} - - - - ) : null} - - {step === 1 ? ( - - - - {c.serverName} - setServerName(event.target.value)} maxLength={100} required /> - {c.serverNameDescription} - - - - {deploymentMode === "hosted" ? c.operationsAdministratorLogin : c.administratorLogin} - - setLogin(event.target.value)} autoComplete="username" maxLength={200} required /> - - - - {deploymentMode === "hosted" ? c.operationsAdministratorPassword : c.administratorPassword} - - setPassword(event.target.value)} autoComplete="new-password" minLength={8} maxLength={1024} required /> - - {deploymentMode === "hosted" ? c.operationsPasswordDescription : c.passwordDescription} - - - {deploymentMode === "hosted" ? ( - - {c.registration} - { - const value = values[0] - if (value === "disabled" || value === "public") setRegistrationPolicy(value) - }} - variant="outline" - spacing={0} - > - {c.disabled} - {c.public} - - {c.registrationDescription} - - ) : null} - {deploymentMode === "hosted" ? ( - - - {c.internalTitle} - {c.internalDescription} - - ) : null} - - - ) : null} - - {step === 2 ? ( - -
- - - - - {deploymentMode === "hosted" - ? - : null} - - - - {c.beforePublicTitle} - {c.beforePublicDescription} - - {error ? ( - - - {c.failed} - {error} - - ) : null} -
-
- ) : null} -
+ + + + {c.administratorLogin} + setLogin(event.target.value)} autoComplete="username" maxLength={200} autoFocus required /> + + + {c.administratorPassword} + setPassword(event.target.value)} autoComplete="new-password" minLength={8} maxLength={1024} required /> + {c.passwordDescription} + + + {c.confirmPassword} + setConfirmPassword(event.target.value)} autoComplete="new-password" minLength={8} maxLength={1024} aria-invalid={passwordMismatch || undefined} required /> + {passwordMismatch ? {c.passwordMismatch} : null} + + + + + {c.securityTitle} + {c.securityDescription} + + {error ? ( + + + {c.failed} + {error} + + ) : null} - - - @@ -409,88 +207,20 @@ export function InstallationGuide({ ) } -function StepSection({ - title, - description, - icon: Icon, - children, -}: { - title: string - description: string - icon: typeof ServerIcon - children: ReactNode -}) { - return ( -
-
- -
-

{title}

-

{description}

-
-
- {children} -
- ) -} - -function ReviewItem({ icon: Icon, title, description }: { icon: typeof ServerIcon, title: string, description: string }) { - return ( - - - - {title} - {description} - - - ) -} - function InstallationLayout({ children }: { children: ReactNode }) { - const { locale } = useLocale() - const c = COPY[locale] - return ( -
-
-
- -
-

{c.product}

-

{c.guide}

-
-
-
- - -
-
- {children} -
- ) + return
{children}
} function installationErrorMessage(cause: unknown, locale: Locale): string { if (locale === "zh-CN") { if (isApiRequestError(cause)) { - const messages: Record = { - installation_already_completed: "实例已经完成安装。", - installation_existing_data: "数据库中已经存在账号数据,不能作为全新实例初始化。", - installation_server_name_invalid: "服务名称不能为空,且不能超过 100 个字符。", - installation_administrator_required: "必须创建首位管理员账号。", - installation_administrator_invalid: "管理员账号格式不正确。", - } + const messages: Record = { installation_already_completed: "实例已经完成安装。", installation_existing_data: "数据库中已经存在账号数据,不能作为全新实例初始化。", installation_server_name_invalid: "服务名称配置无效。", installation_administrator_required: "必须创建首位管理员账号。", installation_administrator_invalid: "管理员账号格式不正确。" } return messages[cause.code] ?? `安装请求失败(HTTP ${cause.status})。` } return cause instanceof TypeError ? "无法连接同步服务器,请确认服务已启动。" : "安装未完成,请稍后重试。" } if (isApiRequestError(cause)) { - const messages: Record = { - installation_already_completed: "This instance has already been installed.", - installation_existing_data: "The database already contains account data and cannot be initialized as a new instance.", - installation_server_name_invalid: "Enter a service name no longer than 100 characters.", - installation_administrator_required: "An initial administrator account is required.", - installation_administrator_invalid: "The administrator account format is invalid.", - } + const messages: Record = { installation_already_completed: "This instance has already been installed.", installation_existing_data: "The database already contains account data and cannot be initialized as a new instance.", installation_server_name_invalid: "The configured service name is invalid.", installation_administrator_required: "An initial administrator account is required.", installation_administrator_invalid: "The administrator account format is invalid." } return messages[cause.code] ?? `The installation request failed (HTTP ${cause.status}).` } return cause instanceof TypeError ? "Could not connect to the sync server. Make sure it is running." : "Installation did not finish. Try again." diff --git a/apps/web/components/invitation-acceptance.tsx b/apps/web/components/invitation-acceptance.tsx new file mode 100644 index 0000000..d9fe924 --- /dev/null +++ b/apps/web/components/invitation-acceptance.tsx @@ -0,0 +1,81 @@ +"use client" + +import { useEffect, useState, type FormEvent } from "react" +import Link from "next/link" +import { CircleCheckIcon, TicketIcon } from "lucide-react" + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button, buttonVariants } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Spinner } from "@/components/ui/spinner" +import { apiRequest, userFacingErrorMessage, type Account } from "@/lib/api" +import { PublicSiteShell } from "@/components/site-chrome" + +interface InvitationInspection { + canContinue: boolean + requiresEmail: boolean + serverName: string +} + +export function InvitationAcceptance() { + const [inspection, setInspection] = useState(null) + const [login, setLogin] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [busy, setBusy] = useState(true) + const [fatalError, setFatalError] = useState("") + const [submitError, setSubmitError] = useState("") + const token = typeof window === "undefined" ? "" : new URLSearchParams(window.location.search).get("token") ?? "" + const mismatch = confirmPassword.length > 0 && password !== confirmPassword + + async function inspectInvitation() { + if (!token) { setBusy(false); setFatalError("邀请链接不完整,请重新打开管理员发送的完整链接。"); return } + setBusy(true) + setInspection(null) + setFatalError("") + try { + const result = await apiRequest("/v1/invitations/inspect", { + method: "POST", body: JSON.stringify({ token }), + }) + setInspection(result) + if (!result.canContinue) setFatalError("邀请链接无效、已经过期或已被使用。") + } catch (cause) { + setFatalError(userFacingErrorMessage(cause)) + } finally { + setBusy(false) + } + } + + useEffect(() => { + void inspectInvitation() + // The bearer token is immutable for the lifetime of this page. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]) + + async function accept(event: FormEvent) { + event.preventDefault() + if (!inspection?.canContinue || mismatch) return + setBusy(true); setSubmitError("") + try { + await apiRequest<{ account: Account }>("/v1/web/auth/register/invitation", { + method: "POST", + body: JSON.stringify({ token, login, password, ...(inspection.requiresEmail ? { email } : {}) }), + }) + window.location.assign("/") + } catch (cause) { + setSubmitError(userFacingErrorMessage(cause)) + } finally { setBusy(false) } + } + + return
+ +
接受 NoteGen 邀请{inspection?.serverName ? `创建账号并加入 ${inspection.serverName}。` : "正在检查邀请链接…"}
+ + {busy && !inspection ?
: fatalError ?
无法使用邀请{fatalError}
返回登录
: inspection?.canContinue ?
邀请有效填写下面的信息即可创建账号,完成后会自动登录。{submitError ? 暂时无法创建账号{submitError} 请修改后重试。 : null}账号 { setLogin(event.target.value); setSubmitError("") }} required />{inspection.requiresEmail ? 受邀邮箱 { setEmail(event.target.value); setSubmitError("") }} required />请输入收到邀请邮件的邮箱地址,用于确认你是受邀人。 : null}密码 { setPassword(event.target.value); setSubmitError("") }} required />至少 8 个字符。确认密码 { setConfirmPassword(event.target.value); setSubmitError("") }} aria-invalid={mismatch || undefined} required />{mismatch ? 两次输入的密码不一致。 : null}
: null} +
+
+
+} diff --git a/apps/web/components/locale-provider.tsx b/apps/web/components/locale-provider.tsx index 2a2926f..af11c82 100644 --- a/apps/web/components/locale-provider.tsx +++ b/apps/web/components/locale-provider.tsx @@ -18,7 +18,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) { const stored = window.localStorage.getItem("notegen-locale") const nextLocale = stored === "en" || stored === "zh-CN" ? stored - : window.navigator.language.toLowerCase().startsWith("zh") ? "zh-CN" : "en" + : "zh-CN" setLocaleState(nextLocale) document.documentElement.lang = nextLocale }, []) diff --git a/apps/web/components/notegen-brand.tsx b/apps/web/components/notegen-brand.tsx new file mode 100644 index 0000000..87ba97c --- /dev/null +++ b/apps/web/components/notegen-brand.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils" + +export const NOTEGEN_SITE_URL = "https://notegen.top" +export const NOTEGEN_DOCS_URL = "https://notegen.top/cn/docs" +export const NOTEGEN_LOGO_URL = "https://s2.loli.net/2025/08/05/IceAMqnBJytp2wE.png" + +export function NoteGenMark({ className }: { className?: string }) { + return ( + + ) +} + +export function NoteGenWordmark({ className }: { className?: string }) { + return NOTEGEN. +} + +export function GitHubMark({ className }: { className?: string }) { + return ( + + ) +} diff --git a/apps/web/components/operations-center.tsx b/apps/web/components/operations-center.tsx index 8ed524c..242edc6 100644 --- a/apps/web/components/operations-center.tsx +++ b/apps/web/components/operations-center.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { MailIcon, SendIcon, Settings2Icon, TicketIcon } from "lucide-react" +import { CopyIcon, MailIcon, SendIcon, Settings2Icon, TicketIcon } from "lucide-react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Badge } from "@/components/ui/badge" @@ -14,7 +14,7 @@ import { Spinner } from "@/components/ui/spinner" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { toast } from "@/components/ui/toast" import { - apiRequest, userFacingErrorMessage, + apiRequest, isApiRequestError, userFacingErrorMessage, type AdminInvitation, type AdminMailItem, type AdminMailStatus, type RuntimeConfiguration, type RuntimeConfigurationResponse, type ServerCapabilities, } from "@/lib/api" @@ -27,6 +27,9 @@ export function OperationsCenter({ refreshVersion = 0 }: { refreshVersion?: numb const [mail, setMail] = useState(null) const [queue, setQueue] = useState([]) const [password, setPassword] = useState("") + const [totpCode, setTotpCode] = useState("") + const [totpRequired, setTotpRequired] = useState(false) + const [latestInvitationUrl, setLatestInvitationUrl] = useState("") const [registration, setRegistration] = useState(null) const [nextPolicy, setNextPolicy] = useState("disabled") const [configuration, setConfiguration] = useState(null) @@ -73,15 +76,31 @@ export function OperationsCenter({ refreshVersion = 0 }: { refreshVersion?: numb if (failed?.status === "rejected") setError(userFacingErrorMessage(failed.reason)) }, []) useEffect(() => { void load() }, [load, refreshVersion]) + + async function requestStepUp(audience: "registration.invitation.create" | "registration.policy.update" | "runtime.configuration.update", requestHash: string) { + try { + const grant = await apiRequest<{ token: string }>("/v1/web/auth/step-up", { + method: "POST", csrf: true, + body: JSON.stringify({ audience, requestHash, password, ...(totpCode.length === 6 ? { totpCode } : {}) }), + }) + setTotpRequired(false) + setTotpCode("") + return grant + } catch (cause) { + if (isApiRequestError(cause) && cause.code === "totp_required") setTotpRequired(true) + throw cause + } + } + async function createInvitation() { const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() const body = { expiresAt, maxUses: 1, send: false } setBusy(true); setError("") try { const requestHash = await digest(body) - const grant = await apiRequest<{ token: string }>("/v1/web/auth/step-up", { method: "POST", csrf: true, body: JSON.stringify({ audience: "registration.invitation.create", requestHash, password }) }) + const grant = await requestStepUp("registration.invitation.create", requestHash) const invitation = await apiRequest<{ url: string }>("/v1/web/admin/invitations", { method: "POST", csrf: true, headers: { "x-step-up-token": grant.token }, body: JSON.stringify(body) }) - setPassword(""); toast.add({ title: "邀请已创建", description: invitation.url, type: "success" }); await load() + setPassword(""); setLatestInvitationUrl(invitation.url); toast.add({ title: "邀请已创建", description: "请复制并发送给受邀用户。", type: "success" }); await load() } catch (cause) { setError(userFacingErrorMessage(cause)) } finally { setBusy(false) } } @@ -90,7 +109,7 @@ export function OperationsCenter({ refreshVersion = 0 }: { refreshVersion?: numb setBusy(true); setError("") try { const requestHash = await digest(body) - const grant = await apiRequest<{ token: string }>("/v1/web/auth/step-up", { method: "POST", csrf: true, body: JSON.stringify({ audience: "registration.policy.update", requestHash, password }) }) + const grant = await requestStepUp("registration.policy.update", requestHash) await apiRequest("/v1/web/admin/registration-policy", { method: "PUT", csrf: true, headers: { "x-step-up-token": grant.token }, body: JSON.stringify(body) }) setPassword(""); toast.add({ title: "注册策略已保存", type: "success" }); await load() } catch (cause) { setError(userFacingErrorMessage(cause)) } @@ -98,6 +117,10 @@ export function OperationsCenter({ refreshVersion = 0 }: { refreshVersion?: numb } async function updateConfiguration() { if (!configuration) return + if (totpRequired && totpCode.length !== 6) { + setError("请输入验证器中的 6 位验证码。") + return + } setBusy(true); setError("") try { const body = { @@ -106,10 +129,7 @@ export function OperationsCenter({ refreshVersion = 0 }: { refreshVersion?: numb ...(clearSmtpPassword ? { smtpPassword: null } : smtpPassword.length > 0 ? { smtpPassword } : {}), } const requestHash = await digest(body) - const grant = await apiRequest<{ token: string }>("/v1/web/auth/step-up", { - method: "POST", csrf: true, - body: JSON.stringify({ audience: "runtime.configuration.update", requestHash, password }), - }) + const grant = await requestStepUp("runtime.configuration.update", requestHash) const response = await apiRequest("/v1/web/admin/configuration", { method: "PUT", csrf: true, headers: { "x-step-up-token": grant.token }, body: JSON.stringify(body), }) @@ -128,14 +148,17 @@ export function OperationsCenter({ refreshVersion = 0 }: { refreshVersion?: numb setConfiguration((current) => current === null ? null : { ...current, [key]: Number(value) }) } const configurationDirty = JSON.stringify(configuration) !== JSON.stringify(savedConfiguration) + const stepUpReady = password.length >= 8 && (!totpRequired || totpCode.length === 6) return
{error ? 实例运维操作失败{error} : null} + {totpRequired ? 还需要双因素验证码输入验证器当前显示的 6 位数字,然后再次执行刚才的保存或创建操作。6 位验证码 setTotpCode(event.target.value.replace(/\D/g, "").slice(0, 6))} /> : null} + {latestInvitationUrl ? 邀请链接已创建
: null} {configuration ? {configurationDirty ? "运行配置有未保存更改" : "运行配置已保存"}{lastSavedAt ? `最近保存 ${formatRelativeTime(lastSavedAt)}。` : `当前配置版本 ${configurationRevision}。`} : null} - 注册策略{registration?.deploymentMode === "hosted" ? "官方托管实例的策略由控制面管理,此处仅显示当前状态。" : "保存后立即写入实例数据库,不需要重启服务或修改环境变量。"}允许新账号加入 { if (value) setNextPolicy(value as RegistrationPolicy) }}>关闭仅邀请公开{nextPolicy === "public" ? "任何人都可以创建账号。" : nextPolicy === "invitation" ? "仅持有管理员邀请链接的用户可注册。" : "不接受新的注册请求。"}{registration?.deploymentMode !== "hosted" ? 当前密码 setPassword(event.target.value)} autoComplete="current-password" />修改实例策略需要重新验证当前密码。 : null} - 邀请注册创建的链接只显示一次;仅在“仅邀请”策略下可用。当前密码 setPassword(event.target.value)} autoComplete="current-password" />默认创建一条 7 天有效、仅使用一次且不发送邮件的邀请。{invitations.map((invitation) => {invitation.tokenHint}使用 {invitation.useCount}/{invitation.maxUses} · 过期 {formatRelativeTime(invitation.expiresAt)}{invitation.revokedAt ? "已撤销" : invitation.delivery?.status ?? "可用"})} + 注册策略保存后立即写入实例数据库,不需要重启服务或修改环境变量。允许新账号加入 { if (value) setNextPolicy(value as RegistrationPolicy) }}>关闭仅邀请公开{nextPolicy === "public" ? "任何人都可以创建账号。" : nextPolicy === "invitation" ? "仅持有管理员邀请链接的用户可注册。" : "不接受新的注册请求。"}当前密码 setPassword(event.target.value)} autoComplete="current-password" />修改实例策略需要重新验证当前密码。 + 邀请注册创建的链接只显示一次;仅在“仅邀请”策略下可用。当前密码 setPassword(event.target.value)} autoComplete="current-password" />默认创建一条 7 天有效、仅使用一次且不发送邮件的邀请。{invitations.map((invitation) => {invitation.tokenHint}使用 {invitation.useCount}/{invitation.maxUses} · 过期 {formatRelativeTime(invitation.expiresAt)}{invitation.revokedAt ? "已撤销" : invitation.delivery?.status ?? "可用"})} SMTP 状态仅展示脱敏队列元数据,不显示收件人、正文、密码或服务商原始响应。{registration?.instanceCapabilities["operations.smtpAdmin"] === true ? <>
{mail?.configured ? "已配置" : "未配置"}{mail?.health ?? "unknown"}{Object.entries(mail?.queue ?? {}).map(([status, count]) => {status} {count})}
{queue.map((item) => {item.template}尝试 {item.attempts}/{item.maxAttempts} · 创建于 {formatRelativeTime(item.createdAt)}{item.status})}{!queue.length ?

没有待展示的投递任务。

: null} : SMTP 管理尚未启用可以先保存安全的 SMTP 运行配置;功能状态和启用条件会显示在“实验功能”页面。}
- {configuration ? 运行配置这些设置保存在实例数据库中,保存后立即生效,无需修改 env 或重启。当前版本 {configurationRevision}。实例名称 setConfiguration({ ...configuration, serverName: event.target.value })} /> setNumber("maxObjectBytes", value)} /> setNumber("maxBlobBytes", value)} /> setNumber("changeRetentionDays", value)} /> setNumber("versionRetentionDays", value)} /> setNumber("tombstoneRetentionDays", value)} /> setNumber("pendingEmailVerificationDays", value)} /> setNumber("accountDeletionCoolingOffDays", value)} /> setNumber("accountDeletionRetentionDays", value)} />邮件默认语言 { if (value) setConfiguration({ ...configuration, mailDefaultLocale: value as "en" | "zh-CN" }) }}>中文English当前密码 setPassword(event.target.value)} autoComplete="current-password" />保存运行配置需要重新验证当前密码。
: null} - {configuration ? SMTP 配置SMTP 密码使用实例密钥加密保存且不会回显;修改后邮件工作线程会自动切换到新连接。邮件投递 { if (value) setConfiguration({ ...configuration, mailDriver: value as "disabled" | "smtp" }) }}>关闭SMTPSMTP 主机 setConfiguration({ ...configuration, smtpHost: event.target.value })} /> setNumber("smtpPort", value)} />发件地址 setConfiguration({ ...configuration, mailFromAddress: event.target.value })} />发件名称 setConfiguration({ ...configuration, mailFromName: event.target.value })} />回复地址 setConfiguration({ ...configuration, mailReplyTo: event.target.value })} />SMTP 用户名 setConfiguration({ ...configuration, smtpUsername: event.target.value })} />SMTP 密码 setSmtpPassword(event.target.value)} autoComplete="new-password" />{clearSmtpPassword ? "保存后将清除密码。" : "密码不会从服务器返回。"}TLS 模式 { if (value) setConfiguration({ ...configuration, smtpTlsMode: value as RuntimeConfiguration["smtpTlsMode"] }) }}>STARTTLSTLS可选 setNumber("smtpConnectTimeoutMs", value)} /> setNumber("smtpCommandTimeoutMs", value)} />凭据状态
{configuration.smtpPasswordConfigured ? "密码已配置" : "无密码"}
管理员当前密码 setPassword(event.target.value)} autoComplete="current-password" />
: null} + {configuration ? 运行配置这些设置保存在实例数据库中,保存后立即生效,无需修改 env 或重启。当前版本 {configurationRevision}。实例名称 setConfiguration({ ...configuration, serverName: event.target.value })} /> setNumber("maxObjectBytes", value)} /> setNumber("maxBlobBytes", value)} /> setNumber("changeRetentionDays", value)} /> setNumber("versionRetentionDays", value)} /> setNumber("tombstoneRetentionDays", value)} /> setNumber("pendingEmailVerificationDays", value)} /> setNumber("accountDeletionCoolingOffDays", value)} /> setNumber("accountDeletionRetentionDays", value)} />邮件默认语言 { if (value) setConfiguration({ ...configuration, mailDefaultLocale: value as "en" | "zh-CN" }) }}>中文English当前密码 setPassword(event.target.value)} autoComplete="current-password" />保存运行配置需要重新验证当前密码。
: null} + {configuration ? SMTP 配置SMTP 密码使用实例密钥加密保存且不会回显;修改后邮件工作线程会自动切换到新连接。邮件投递 { if (value) setConfiguration({ ...configuration, mailDriver: value as "disabled" | "smtp" }) }}>关闭SMTPSMTP 主机 setConfiguration({ ...configuration, smtpHost: event.target.value })} /> setNumber("smtpPort", value)} />发件地址 setConfiguration({ ...configuration, mailFromAddress: event.target.value })} />发件名称 setConfiguration({ ...configuration, mailFromName: event.target.value })} />回复地址 setConfiguration({ ...configuration, mailReplyTo: event.target.value })} />SMTP 用户名 setConfiguration({ ...configuration, smtpUsername: event.target.value })} />SMTP 密码 setSmtpPassword(event.target.value)} autoComplete="new-password" />{clearSmtpPassword ? "保存后将清除密码。" : "密码不会从服务器返回。"}TLS 模式 { if (value) setConfiguration({ ...configuration, smtpTlsMode: value as RuntimeConfiguration["smtpTlsMode"] }) }}>STARTTLSTLS可选 setNumber("smtpConnectTimeoutMs", value)} /> setNumber("smtpCommandTimeoutMs", value)} />凭据状态
{configuration.smtpPasswordConfigured ? "密码已配置" : "无密码"}
管理员当前密码 setPassword(event.target.value)} autoComplete="current-password" />
: null}
} diff --git a/apps/web/components/operations-portal.tsx b/apps/web/components/operations-portal.tsx deleted file mode 100644 index 07363e0..0000000 --- a/apps/web/components/operations-portal.tsx +++ /dev/null @@ -1,456 +0,0 @@ -"use client" - -import { useEffect, useMemo, useState, type FormEvent } from "react" -import { - ActivityIcon, - BadgeDollarSignIcon, - Building2Icon, - CalendarClockIcon, - CircleAlertIcon, - CircleCheckIcon, - DatabaseIcon, - HeadsetIcon, - KeyRoundIcon, - LogInIcon, - ShieldAlertIcon, - ShieldCheckIcon, - UserRoundCogIcon, - UsersIcon, -} from "lucide-react" - -import { LanguageToggle } from "@/components/language-toggle" -import { useLocale, type Locale } from "@/components/locale-provider" -import { - OperationsShell, - permittedOperationsSections, - type OperationsSection, -} from "@/components/operations-shell" -import { ThemeToggle } from "@/components/theme-toggle" -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemGroup, - ItemMedia, - ItemTitle, -} from "@/components/ui/item" -import { Spinner } from "@/components/ui/spinner" -import { apiRequest, isApiRequestError } from "@/lib/api" - -export interface StaffProfile { - id: string - login: string - displayName: string - roles: string[] - permissions: string[] -} - -interface OperationsOverview { - accountCount: number - activeAccountCount: number - newAccountCount: number - activeSubscriptionCount: number - openSupportCaseCount: number - urgentSupportCaseCount: number - reviewRiskEventCount: number - pendingDataRequestCount: number - activeStaffSessionCount: number - generatedAt: string -} - -interface OperationsAccount { - id: string - login: string - identityState: string - status: string - workspaceCount: number - deviceCount: number - subscriptionStatus: string | null - createdAt: string -} - -interface SupportCase { - id: string - category: string - severity: string - status: string - subject: string - assignedStaffId: string | null - lastMessageAt: string | null - createdAt: string - updatedAt: string -} - -interface Subscription { - id: string - accountId: string | null - accountLogin: string | null - provider: string - status: string - isCurrent: boolean - currentPeriodEnd: string | null - createdAt: string -} - -interface RiskEvent { - id: string - eventType: string - accountId: string | null - accountLogin: string | null - outcome: string - reasonCodes: string[] - score: number | null - createdAt: string -} - -interface DataRequest { - id: string - accountId: string | null - accountLogin: string | null - type: string - status: string - requestChannel: string - dueAt: string | null - createdAt: string -} - -const LOGIN_COPY = { - "zh-CN": { - product: "NoteGen 运营后台", - realm: "独立运营账号", - title: "登录运营管理后台", - description: "使用安装时创建的运营管理员账号。客户账号无法登录这里。", - login: "运营管理员账号", - password: "密码", - passwordDescription: "该凭据只属于运营权限域,不与客户账号共享。", - submit: "进入运营后台", - submitting: "正在登录", - loading: "正在检查运营登录状态", - failed: "操作未完成", - }, - en: { - product: "NoteGen Operations", - realm: "Separate operations account", - title: "Sign in to operations", - description: "Use the operations administrator created during installation. Customer accounts cannot sign in here.", - login: "Operations administrator account", - password: "Password", - passwordDescription: "This credential belongs only to the operations realm and is not shared with customer accounts.", - submit: "Enter operations", - submitting: "Signing in", - loading: "Checking the operations session", - failed: "The operation did not complete", - }, -} as const - -export function OperationsPortal() { - const { locale } = useLocale() - const c = LOGIN_COPY[locale] - const [profile, setProfile] = useState(null) - const [login, setLogin] = useState("") - const [password, setPassword] = useState("") - const [section, setSection] = useState("overview") - const [overview, setOverview] = useState(null) - const [accounts, setAccounts] = useState([]) - const [supportCases, setSupportCases] = useState([]) - const [subscriptions, setSubscriptions] = useState([]) - const [riskEvents, setRiskEvents] = useState([]) - const [dataRequests, setDataRequests] = useState([]) - const [accountQuery, setAccountQuery] = useState("") - const [appliedAccountQuery, setAppliedAccountQuery] = useState("") - const [loading, setLoading] = useState(true) - const [busy, setBusy] = useState(false) - const [dataLoading, setDataLoading] = useState(false) - const [refreshVersion, setRefreshVersion] = useState(0) - const [error, setError] = useState("") - - useEffect(() => { - let active = true - apiRequest<{ activationPending: boolean }>("/v1/installation/status") - .then((status) => { - if (status.activationPending) { - window.location.replace("/") - return null - } - return apiRequest<{ staff: StaffProfile }>("/v1/staff/session") - }) - .then((result) => { - if (!active || result === null) return - setProfile(result.staff) - setSection(permittedOperationsSections(result.staff)[0] ?? "access") - }) - .catch((cause) => { - if (active && (!isApiRequestError(cause) || cause.status !== 401)) setError(staffErrorMessage(cause, locale)) - }) - .finally(() => { if (active) setLoading(false) }) - return () => { active = false } - }, [locale]) - - useEffect(() => { - if (profile === null || section === "access") return - let active = true - setDataLoading(true) - setError("") - const request = section === "overview" - ? apiRequest("/v1/staff/operations/overview").then(setOverview) - : section === "accounts" - ? apiRequest(`/v1/staff/operations/accounts?limit=100&query=${encodeURIComponent(appliedAccountQuery)}`).then(setAccounts) - : section === "support" - ? apiRequest("/v1/staff/operations/support/cases").then(setSupportCases) - : section === "billing" - ? apiRequest("/v1/staff/operations/billing/subscriptions?limit=100").then(setSubscriptions) - : section === "risk" - ? apiRequest("/v1/staff/operations/risk/events?limit=100").then(setRiskEvents) - : apiRequest("/v1/staff/operations/compliance/requests?limit=100").then(setDataRequests) - request.catch((cause) => { - if (!active) return - if (isApiRequestError(cause) && cause.status === 401) setProfile(null) - setError(staffErrorMessage(cause, locale)) - }).finally(() => { if (active) setDataLoading(false) }) - return () => { active = false } - }, [profile, section, appliedAccountQuery, refreshVersion, locale]) - - async function handleLogin(event: FormEvent) { - event.preventDefault() - setBusy(true) - setError("") - try { - const result = await apiRequest<{ staff: StaffProfile }>("/v1/staff/auth/login", { - method: "POST", - body: JSON.stringify({ login, password }), - }) - setPassword("") - setProfile(result.staff) - setSection(permittedOperationsSections(result.staff)[0] ?? "access") - } catch (cause) { - setError(staffErrorMessage(cause, locale)) - } finally { - setBusy(false) - } - } - - async function handleLogout() { - setBusy(true) - setError("") - try { - await apiRequest("/v1/staff/auth/logout", { method: "POST", csrf: true }) - setProfile(null) - setOverview(null) - } catch (cause) { - setError(staffErrorMessage(cause, locale)) - } finally { - setBusy(false) - } - } - - const counts = useMemo(() => ({ - accounts: overview?.accountCount, - support: overview?.openSupportCaseCount, - billing: overview?.activeSubscriptionCount, - risk: overview?.reviewRiskEventCount, - compliance: overview?.pendingDataRequestCount, - }), [overview]) - - if (loading) return {c.loading} - if (profile === null) return ( - - - - {c.realm} - {c.title} - {c.description} - -
- - - - {c.login} - setLogin(event.target.value)} autoComplete="username" maxLength={200} required /> - - - {c.password} - setPassword(event.target.value)} autoComplete="current-password" minLength={8} maxLength={1024} required /> - {c.passwordDescription} - - {error ? {c.failed}{error} : null} - - - - - -
-
-
- ) - - return ( - setRefreshVersion((value) => value + 1)} - onLogout={() => void handleLogout()} - > - {error ? {c.failed}{error} : null} - {dataLoading ? : null} - {!dataLoading && section === "overview" ? : null} - {!dataLoading && section === "accounts" ? setAppliedAccountQuery(accountQuery.trim())} /> : null} - {!dataLoading && section === "support" ? : null} - {!dataLoading && section === "billing" ? : null} - {!dataLoading && section === "risk" ? : null} - {!dataLoading && section === "compliance" ? : null} - {section === "access" ? : null} - - ) -} - -function LoginFrame({ children }: { children: React.ReactNode }) { - const { locale } = useLocale() - const c = LOGIN_COPY[locale] - return ( -
-
-
- -

{c.product}

{c.realm}

-
-
-
-
{children}
-
- ) -} - -function LoadingPanel() { - return 正在加载运营数据 -} - -function OverviewPanel({ overview, locale, onNavigate }: { overview: OperationsOverview | null, locale: Locale, onNavigate: (section: OperationsSection) => void }) { - if (overview === null) return - const metrics = [ - { label: locale === "zh-CN" ? "客户账号" : "Customer accounts", value: overview.accountCount, helper: `${overview.activeAccountCount} ${locale === "zh-CN" ? "个可用" : "active"}`, icon: UsersIcon, section: "accounts" as const }, - { label: locale === "zh-CN" ? "近 7 天新增" : "New in 7 days", value: overview.newAccountCount, helper: locale === "zh-CN" ? "新增客户" : "new customers", icon: ActivityIcon, section: "accounts" as const }, - { label: locale === "zh-CN" ? "有效订阅" : "Active subscriptions", value: overview.activeSubscriptionCount, helper: locale === "zh-CN" ? "含试用与宽限期" : "including trial and grace", icon: BadgeDollarSignIcon, section: "billing" as const }, - { label: locale === "zh-CN" ? "待处理工单" : "Open support cases", value: overview.openSupportCaseCount, helper: `${overview.urgentSupportCaseCount} ${locale === "zh-CN" ? "个紧急" : "urgent"}`, icon: HeadsetIcon, section: "support" as const }, - ] - return ( -
-
- {metrics.map((metric) => { - const Icon = metric.icon - return onNavigate(metric.section)}>{metric.label}{metric.value}{metric.helper} - })} -
-
- {locale === "zh-CN" ? "待办与风险" : "Queues and risk"}{locale === "zh-CN" ? "需要运营团队持续关注的跨域队列。" : "Cross-domain queues that need operations attention."} - onNavigate("risk")} /> - onNavigate("compliance")} /> - onNavigate("support")} /> - - {locale === "zh-CN" ? "运营状态" : "Operations status"}{formatDate(overview.generatedAt, locale)}
{locale === "zh-CN" ? "运营会话" : "Staff sessions"}{overview.activeStaffSessionCount}
{locale === "zh-CN" ? "控制台状态" : "Console status"}{locale === "zh-CN" ? "正常" : "Healthy"}
-
-
- ) -} - -function QueueItem({ icon: Icon, title, value, locale, onClick }: { icon: typeof UsersIcon, title: string, value: number, locale: Locale, onClick: () => void }) { - const description = value === 0 - ? (locale === "zh-CN" ? "当前队列为空" : "The queue is currently empty") - : (locale === "zh-CN" ? "点击进入队列查看详情" : "Open the queue to review details") - return }>{title}{description} 0 ? "secondary" : "outline"}>{value} -} - -function AccountsPanel({ accounts, query, locale, onQueryChange, onSearch }: { accounts: OperationsAccount[], query: string, locale: Locale, onQueryChange: (value: string) => void, onSearch: () => void }) { - return {locale === "zh-CN" ? "客户账号目录" : "Customer account directory"}{locale === "zh-CN" ? "按登录名检索,查看账号可用性、工作区、设备和当前订阅。" : "Search by login and inspect availability, workspaces, devices, and subscription."}
{ event.preventDefault(); onSearch() }}> onQueryChange(event.target.value)} placeholder={locale === "zh-CN" ? "输入账号或邮箱" : "Enter account or email"} maxLength={200} />
{accounts.map((account) => {account.login}{formatDate(account.createdAt, locale)} · {account.workspaceCount} {locale === "zh-CN" ? "个工作区" : "workspaces"} · {account.deviceCount} {locale === "zh-CN" ? "台设备" : "devices"}{account.subscriptionStatus ?? (locale === "zh-CN" ? "无订阅" : "No subscription")})}
-} - -function SupportPanel({ cases, locale }: { cases: SupportCase[], locale: Locale }) { - return {cases.map((item) => {item.subject}{item.category} · {formatDate(item.updatedAt, locale)} · {item.assignedStaffId ? (locale === "zh-CN" ? "已分配" : "Assigned") : (locale === "zh-CN" ? "未分配" : "Unassigned")}{item.severity})} -} - -function BillingPanel({ subscriptions, locale }: { subscriptions: Subscription[], locale: Locale }) { - return {subscriptions.map((item) => {item.accountLogin ?? (locale === "zh-CN" ? "已脱离账号" : "Detached account")}{item.provider} · {item.currentPeriodEnd ? `${locale === "zh-CN" ? "周期至" : "Period ends"} ${formatDate(item.currentPeriodEnd, locale)}` : formatDate(item.createdAt, locale)}{item.isCurrent ? {locale === "zh-CN" ? "当前" : "Current"} : null})} -} - -function RiskPanel({ events, locale }: { events: RiskEvent[], locale: Locale }) { - return {events.map((item) => {item.eventType}{item.accountLogin ?? (locale === "zh-CN" ? "匿名主体" : "Anonymous subject")} · {formatDate(item.createdAt, locale)}{item.reasonCodes.length ? ` · ${item.reasonCodes.join(", ")}` : ""}{item.score !== null ? {locale === "zh-CN" ? "分值" : "Score"} {item.score} : null})} -} - -function CompliancePanel({ requests, locale }: { requests: DataRequest[], locale: Locale }) { - return {requests.map((item) => {item.accountLogin ?? (locale === "zh-CN" ? "已匿名化主体" : "Anonymized subject")}{item.type} · {item.requestChannel} · {item.dueAt ? `${locale === "zh-CN" ? "截止" : "Due"} ${formatDate(item.dueAt, locale)}` : formatDate(item.createdAt, locale)})} -} - -function AccessPanel({ profile, locale }: { profile: StaffProfile, locale: Locale }) { - return
{locale === "zh-CN" ? "当前运营管理员" : "Current operations administrator"}{profile.login}

{profile.displayName}

{locale === "zh-CN" ? "运营身份与客户身份独立存储。" : "Operations and customer identities are stored separately."}

{profile.roles.map((role) => {role})}
{locale === "zh-CN" ? "已授权能力" : "Granted capabilities"}{profile.permissions.length}{profile.permissions.map((permission) => {permission})}{locale === "zh-CN" ? "内部测试认证" : "Internal-test authentication"}{locale === "zh-CN" ? "当前使用本地运营凭据。正式环境接入企业身份登录后,客户账号体系仍保持独立。" : "Local operations credentials are active. Customer identities remain separate when enterprise sign-in is connected."}
-} - -function RecordCard({ title, description, empty, locale, children }: { title: string, description: string, empty: boolean, locale: Locale, children: React.ReactNode }) { - return {title}{description}{children} -} - -function RecordList({ empty, locale, children }: { empty: boolean, locale: Locale, children: React.ReactNode }) { - if (empty) return {locale === "zh-CN" ? "当前没有记录" : "No records right now"}{locale === "zh-CN" ? "有新数据后会自动出现在这里。" : "New data will appear here when available."} - return {children} -} - -function NoticeCard({ title }: { title: string }) { - return {title} -} - -function StatusBadge({ value }: { value: string }) { - const positive = ["active", "trialing", "resolved", "completed", "allow", "ok"].includes(value) - return {value} -} - -function formatDate(value: string, locale: Locale): string { - const date = new Date(value) - if (!Number.isFinite(date.getTime())) return locale === "zh-CN" ? "未知时间" : "Unknown time" - return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date) -} - -function staffErrorMessage(cause: unknown, locale: Locale): string { - if (isApiRequestError(cause)) { - const zh: Record = { - staff_credentials_invalid: "运营管理员账号或密码不正确。", - staff_session_invalid: "运营登录状态已过期,请重新登录。", - staff_permission_denied: "当前运营角色无权访问这个工作区。", - staff_local_auth_unavailable: "当前实例未启用运营模式的内部测试登录。", - csrf_invalid: "页面安全凭据已过期,请刷新后重试。", - origin_not_allowed: "当前页面地址未被服务器信任,请检查 Web 地址配置。", - rate_limited: "操作过于频繁,请稍后再试。", - } - const en: Record = { - staff_credentials_invalid: "The operations administrator account or password is incorrect.", - staff_session_invalid: "The operations session has expired. Sign in again.", - staff_permission_denied: "The current operations role cannot access this workspace.", - staff_local_auth_unavailable: "Internal-test operations sign-in is not enabled for this instance.", - csrf_invalid: "The page security credential has expired. Refresh and try again.", - origin_not_allowed: "This page origin is not trusted by the server. Check the Web URL configuration.", - rate_limited: "There have been too many requests. Try again later.", - } - const message = (locale === "zh-CN" ? zh : en)[cause.code] - if (message) return message - return locale === "zh-CN" ? `请求未完成(HTTP ${cause.status})。` : `The request did not complete (HTTP ${cause.status}).` - } - return locale === "zh-CN" ? "无法连接同步服务器,请确认服务已启动。" : "Could not connect to the sync server. Make sure it is running." -} diff --git a/apps/web/components/operations-shell.tsx b/apps/web/components/operations-shell.tsx deleted file mode 100644 index 6ed8894..0000000 --- a/apps/web/components/operations-shell.tsx +++ /dev/null @@ -1,249 +0,0 @@ -"use client" - -import type { ReactNode } from "react" -import { - BadgeDollarSignIcon, - Building2Icon, - ClipboardCheckIcon, - HeadsetIcon, - KeyRoundIcon, - LayoutDashboardIcon, - LogOutIcon, - RefreshCwIcon, - ShieldAlertIcon, - UsersIcon, -} from "lucide-react" - -import { LanguageToggle } from "@/components/language-toggle" -import { useLocale } from "@/components/locale-provider" -import { ThemeToggle } from "@/components/theme-toggle" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Separator } from "@/components/ui/separator" -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInset, - SidebarMenu, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarProvider, - SidebarRail, - SidebarTrigger, -} from "@/components/ui/sidebar" -import { Spinner } from "@/components/ui/spinner" - -import type { StaffProfile } from "@/components/operations-portal" - -export type OperationsSection = - | "overview" - | "accounts" - | "support" - | "billing" - | "risk" - | "compliance" - | "access" - -const COPY = { - "zh-CN": { - product: "NoteGen 运营后台", - realm: "官方托管 · 内部运营", - group: "运营管理", - role: "运营管理员", - internal: "内部测试", - overview: ["运营总览", "关注增长、服务与风险队列。"], - accounts: ["客户账号", "检索客户并了解账号、设备与订阅状态。"], - support: ["支持工单", "处理等待运营团队响应的客户问题。"], - billing: ["订阅授权", "核对订阅生命周期与权益来源。"], - risk: ["风险中心", "观察近期风险决策与异常信号。"], - compliance: ["合规请求", "跟进数据访问、导出和删除请求。"], - access: ["权限中心", "查看当前运营身份、角色与授权能力。"], - refresh: "刷新当前数据", - logout: "退出运营后台", - }, - en: { - product: "NoteGen Operations", - realm: "Hosted · Internal operations", - group: "Operations", - role: "Operations administrator", - internal: "Internal test", - overview: ["Operations overview", "Monitor growth, service, and risk queues."], - accounts: ["Customer accounts", "Search customers and inspect account, device, and subscription state."], - support: ["Support cases", "Handle customer issues waiting for an operations response."], - billing: ["Subscriptions", "Review subscription lifecycle and entitlement sources."], - risk: ["Risk center", "Monitor recent risk decisions and unusual signals."], - compliance: ["Compliance requests", "Track data access, export, and deletion requests."], - access: ["Access center", "Review the current staff identity, roles, and permissions."], - refresh: "Refresh current data", - logout: "Sign out of operations", - }, -} as const - -const PERMISSION: Partial> = { - overview: "platform.provision", - accounts: "platform.provision", - support: "support.read", - billing: "billing.read", - risk: "risk.read", - compliance: "compliance.request.process", -} - -export function permittedOperationsSections(profile: StaffProfile): OperationsSection[] { - const order: OperationsSection[] = ["overview", "accounts", "support", "billing", "risk", "compliance", "access"] - return order.filter((section) => { - const permission = PERMISSION[section] - return permission === undefined || profile.permissions.includes(permission) - }) -} - -export function OperationsShell({ - profile, - section, - counts, - busy, - onSectionChange, - onRefresh, - onLogout, - children, -}: { - profile: StaffProfile - section: OperationsSection - counts: Partial> - busy: boolean - onSectionChange: (section: OperationsSection) => void - onRefresh: () => void - onLogout: () => void - children: ReactNode -}) { - const { locale } = useLocale() - const c = COPY[locale] - const sections = permittedOperationsSections(profile) - const details = c[section] - const icons = { - overview: LayoutDashboardIcon, - accounts: UsersIcon, - support: HeadsetIcon, - billing: BadgeDollarSignIcon, - risk: ShieldAlertIcon, - compliance: ClipboardCheckIcon, - access: KeyRoundIcon, - } - - return ( - - - - - - - - - - - {c.product} - {c.realm} - - - - - - - - {c.group} - - - {sections.map((item) => { - const Icon = icons[item] - return ( - - onSectionChange(item)} - > - - {c[item][0]} - - {counts[item] !== undefined ? {counts[item]} : null} - - ) - })} - - - - - - - - - }> - - {profile.displayName.slice(0, 1).toUpperCase()} - - - {profile.displayName} - {c.role} - - - - - {profile.login} - onSectionChange("access")}> - - {c.access[0]} - - - - - - {busy ? : } - {c.logout} - - - - - - - - - - - -
-
- -
-

{details[0]}

-

{details[1]}

-
-
-
- {c.internal} - - - -
-
- -
{children}
-
-
- ) -} diff --git a/apps/web/components/site-chrome.tsx b/apps/web/components/site-chrome.tsx new file mode 100644 index 0000000..8ed1ce0 --- /dev/null +++ b/apps/web/components/site-chrome.tsx @@ -0,0 +1,160 @@ +"use client" + +import type { ReactNode } from "react" +import { MenuIcon } from "lucide-react" + +import { LanguageToggle } from "@/components/language-toggle" +import { + NOTEGEN_DOCS_URL, + NOTEGEN_SITE_URL, + GitHubMark, + NoteGenMark, + NoteGenWordmark, +} from "@/components/notegen-brand" +import { ThemeToggle } from "@/components/theme-toggle" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet" + +const productLinks = [ + { label: "工作流", href: `${NOTEGEN_SITE_URL}/cn#workflow` }, + { label: "功能", href: `${NOTEGEN_SITE_URL}/cn#features` }, + { label: "网页剪藏", href: `${NOTEGEN_SITE_URL}/cn/web-clipper/download` }, + { label: "文档", href: NOTEGEN_DOCS_URL }, +] as const + +const moreLinks = [ + { label: "交流群", description: "加入 NoteGen 用户交流群", href: `${NOTEGEN_SITE_URL}/cn/community` }, + { label: "商务合作", description: "了解与 NoteGen 的合作方式", href: `${NOTEGEN_SITE_URL}/cn/business` }, + { label: "支持项目", description: "支持 NoteGen 开源项目", href: `${NOTEGEN_SITE_URL}/cn/donate` }, +] as const + +export function SiteHeader({ instanceName, showLanguage = false }: { + instanceName?: string | null + showLanguage?: boolean +}) { + return ( +
+
+ + + + SYNC + + + + +
+ {instanceName ? {instanceName} : null} +
+ {showLanguage ? : null} + + +
+ + }> + + + + + SYNC + NoteGen 产品与同步服务导航 + + +
+ {showLanguage ? : null} + + +
+
+
+
+
+
+ ) +} + +export function SiteFooter() { + return ( + + ) +} + +export function PublicSiteShell({ children, instanceName, showLanguage = false }: { + children: ReactNode + instanceName?: string | null + showLanguage?: boolean +}) { + return ( +
+ +
{children}
+ +
+ ) +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 91f9408..0f5b271 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -66,7 +66,7 @@ export async function apiRequest( headers.set("content-type", "application/json") } if (init.csrf) { - const token = readCookie(path.startsWith("/v1/staff/") ? "notegen_staff_csrf" : "notegen_csrf") + const token = readCookie("notegen_csrf") if (token) headers.set("x-csrf-token", token) } const timeoutController = new AbortController() @@ -121,6 +121,19 @@ const apiErrorMessages: Record = { setup_token_invalid: "一次性初始化凭据无效或已经过期。", registration_closed: "服务器已关闭公开注册,请联系实例管理员获取邀请。", credentials_invalid: "账号或密码不正确,请检查后重试。", + totp_required: "请输入验证器中的 6 位验证码。", + totp_invalid: "双因素验证码不正确或已经过期,请输入最新验证码。", + email_verification_required: "请先完成邮箱验证,再登录账号。", + email_registration_required: "请使用邮箱注册流程创建账号。", + email_invalid: "请输入有效的邮箱地址。", + email_identity_not_found: "没有找到可用的邮箱账号。", + email_verification_unavailable: "当前服务器尚未启用邮箱验证。", + password_reset_unavailable: "当前服务器尚未启用密码找回。", + action_token_invalid: "链接无效,请重新申请。", + action_token_consumed: "这个链接已经使用过,请重新申请。", + action_token_expired: "链接已经过期,请重新申请。", + invitation_invalid: "邀请链接无效、已经过期或已被使用。", + invitation_not_found: "没有找到这条邀请。", password_unchanged: "新密码不能与当前密码相同。", account_exists: "这个账号已经注册,请切换到“登录”。", account_not_found: "账号不存在或已停用。", diff --git a/docs/account-service/README.md b/docs/account-service/README.md index 7032feb..3f6a80b 100644 --- a/docs/account-service/README.md +++ b/docs/account-service/README.md @@ -1,28 +1,19 @@ # NoteGen 账号服务开发计划总览 -- 状态:已完成(原账号服务开发计划的内部测试范围已实施;尚未选择或启用的生产外部集成、合规政策与恢复能力已迁移至 [13 生产上线准备](13-production-readiness.md),默认暂停) +> 现行产品只维护“独立实例”一种运行方式:官方公共测试服务与自行部署使用相同的账号、管理员和运维边界。本文及子文档中关于 hosted、Staff 运营域、订阅、计费和平台客服的内容仅保留为历史设计记录,不再属于当前交付计划。 + +- 状态:历史设计归档;当前仅继续维护独立实例相关能力 - 日期:2026-08-11 - 面向读者:NoteGen Server、账号 Web、NoteGen 客户端的开发者与部署维护者 -- 文档目标:把现有同步服务演进为可长期运营的账号服务,并允许每个功能按独立任务实施、验收、上线和回滚 +- 文档目标:保留既有设计决策背景;当前实现范围以独立实例为准 ## 1. 结论 -下一阶段不继续扩张同步协议。同步、设备、加密、实时通知、后台管理和基础备份继续作为共享核心;新增能力围绕账号生命周期、运营策略和自托管可维护性展开。 - -产品固定为两种部署形态: +下一阶段不继续扩张同步协议。同步、设备、加密、实时通知、后台管理和基础备份继续作为共享核心;新增能力只围绕独立实例的账号生命周期和可维护性展开。 -| 维度 | 官方托管 `hosted` | 自托管 `self-hosted` | -| --- | --- | --- | -| 身份 | 邮箱为主身份,必须验证,支持找回 | 用户名可继续使用;邮箱与 SMTP 可选 | -| 注册 | 由运营策略、邮箱验证和风控共同决定 | 首次初始化后默认关闭,可启用邀请或公开注册 | -| 风控 | 登录、注册、找回、设备和支付均受策略控制 | 保留本地限流与管理员手工封禁,不依赖外部风控 | -| 配额 | 账号级用量计量与服务端强制执行 | 默认不启用商业配额,可保留实例级安全上限 | -| 订阅 | 支付状态只产生权益,不直接修改同步数据 | 不加载计费入口和支付 Webhook | -| 合规 | 数据导出、删除、保留、同意记录和审计流程 | 提供基础导出/删除工具,法律责任由部署者承担 | -| 客服 | 工单、诊断授权、最小权限客服角色 | 不内置官方客服,可显示部署者配置的支持链接 | -| 运维 | 由官方基础设施和内部 Runbook 负责 | 提供首次初始化、邀请、SMTP、备份恢复和升级助手 | +产品固定为一种运行形态:用户名登录,首次初始化后默认关闭注册,可按需启用邀请或公开注册;邮件与 SMTP 可选;不加载 Staff 运营域、商业配额、订阅、支付、平台客服或官方托管专属控制面。官方公共测试服务也按这一形态部署。 -`deploymentMode` 只决定部署策略和允许启用的能力,不复制同步核心,不建立两套数据库,也不允许前端自行决定权限。 +数据库中的 `deploymentMode` 暂时只作为历史迁移字段保留,不再是产品功能开关。 ## 2. 当前基线与必须先修正的边界 diff --git a/docs/client-protocol.md b/docs/client-protocol.md index 816eb7b..b56b362 100644 --- a/docs/client-protocol.md +++ b/docs/client-protocol.md @@ -1,6 +1,6 @@ # NoteGen 客户端接入协议 -本文说明 NoteGen 客户端接入 Sync Server 时必须遵守的可靠性与安全约束。HTTP 请求和字段的最终定义以服务端 `/openapi.json` 为准。 +本文说明 NoteGen 客户端接入 Sync Server 时必须遵守的可靠性与安全约束。冻结的 v1 总览见 [protocol-v1.md](protocol-v1.md),HTTP 请求和字段的最终定义以服务端 `/openapi.json` 为准。本文中与 v1 总览冲突的早期草案描述均以 v1 为准。 ## 本地状态 @@ -85,12 +85,15 @@ WebSocket 连接后第一条消息必须是: { "type": "authenticate", "accessToken": "...", - "workspaceIds": ["..."] + "workspaceIds": ["..."], + "expectedSyncEpoch": "..." } ``` WebSocket 消息只表示状态可能变化。`workspace.changed` 触发 Pull,`workspace.keys-changed` 触发重新获取 envelope,`workspace.state-changed` 与 `account.workspaces-changed` 触发刷新 Workspace 列表。任何消息都不能直接推进 cursor,也不能假设每条 change 都会收到通知。 +Canvas 拖拽期间可以在 `presence.update.canvas.nodes` 中发送最多 100 个 `{ id, x, y }` 临时位置;拖拽结束后的最终位置仍须使用 durable Yjs command 持久化。 + ## Push 与幂等 - 每次操作生成永久 operation UUID。 @@ -117,13 +120,18 @@ WebSocket 消息只表示状态可能变化。`workspace.changed` 触发 Pull, - change 可被重复接收,应用过程必须幂等。 - ACK 只用于服务端运维;本地持久化 cursor 才是恢复依据。 - `cursor_expired` 必须进入 bootstrap,不能改成从零盲目 Pull。 +- 客户端只有在事件已经写入本地 inbox 且全部成功应用后,才调用 + `POST /v1/workspaces/:workspaceId/sync/ack` 提交 `through`。服务端不会把 + HTTP 响应已发送视为持久确认。 +- 当前服务端声明 `syncEpochFencing` 和 `durableCursorAcknowledgement` 为必需 + 同步特性;所有同步请求必须携带最近一次显式接受的 `expectedSyncEpoch`。 ## Bootstrap -第一页不传 `bootstrapSessionId`。服务端返回固定 `snapshotSequence`;如果还有下一页,同时返回短期 `bootstrapSessionId`。后续每页必须传回该 session: +第一页不传 `bootstrapId`。服务端返回固定 `snapshotSequence` 和短期 `bootstrapId`。后续每页必须传回该 session: ```text -GET .../sync/bootstrap?afterObjectId=&bootstrapSessionId= +GET .../sync/bootstrap?afterObjectId=&bootstrapId=&expectedSyncEpoch= ``` 完成所有页后: @@ -140,7 +148,7 @@ Session 过期时丢弃 staging 中未完成的 manifest 并从第一页重新 1. 客户端先加密完整附件。 2. `ciphertextHash = SHA-256(encryptedBytes)`。 -3. `blobId = HMAC-SHA256(workspaceBlobIdentifierKey, ciphertextHash)`;重试必须复用同一份 encrypted bytes。 +3. v1 使用密文字节的 SHA-256 Base64URL 作为 `blobId`;重试必须复用同一份 encrypted bytes。 4. 创建上传会话并按服务端返回的 `partBytes` 分片。 5. 创建结果为 resumed 时,读取 `uploadedParts`;也可随时 GET 上传会话恢复进度。 6. 每个 part 可使用相同 part number 重传;非最后一片必须正好等于 `partBytes`。 @@ -155,9 +163,9 @@ Session 过期时丢弃 staging 中未完成的 manifest 并从第一页重新 ## Yjs -- 每个 Yjs update 使用独立 object ID 和 `yjs-update` kind。 -- checkpoint 使用 `yjs-checkpoint` kind。 -- update 在客户端加密后通过普通可靠同步层传输。 +- 每个 update 具有独立 `updateId`,通过 durable `append-update` command 写入文档流。 +- checkpoint 通过 durable `commit-checkpoint` command 写入。 +- update 在客户端加密后通过可靠 HTTP command 传输;WebSocket 不承载 update payload。 - 客户端解密后,Yjs update 可以重复、乱序应用。 - 服务端不解析 Awareness,不保存多人光标,也不执行 CRDT 合并。 - 客户端生成 checkpoint 后仍应保留本地旧 update,直到 checkpoint 和对应 Markdown 快照都获得服务端 ACK。 diff --git a/docs/notegen-integration.md b/docs/notegen-integration.md index c055f5f..5951f4f 100644 --- a/docs/notegen-integration.md +++ b/docs/notegen-integration.md @@ -1,6 +1,6 @@ # NoteGen 服务端同步接入指南 -本文面向 NoteGen 客户端维护者,说明如何把 `note-gen-server` 接入桌面端和移动端。它用于指导客户端实现、交互设计和验收,不替代 `/openapi.json` 的字段定义。 +本文面向 NoteGen 客户端维护者,说明如何把 `note-gen-server` 接入桌面端和移动端。字段与错误码以 `/openapi.json` 为唯一来源,冻结的同步语义见 [`protocol-v1.md`](./protocol-v1.md)。 ## 边界 @@ -24,7 +24,6 @@ serverInstanceId serverName accountLogin deviceId -workspaceId protocolVersion lastSuccessfulSyncAt connectionState @@ -44,7 +43,7 @@ Access Token、Refresh Token、设备私钥和同步密钥不进入普通设置 2. `GET /v1/capabilities`:确认服务类型、协议范围、实例身份、限制和注册模式。 3. `GET /health/ready`:确认数据库与 Blob 后端可用。 -界面显示服务端返回的名称、版本和连接安全状态。非本机地址使用明文 HTTP 时应阻止继续;证书错误不能提供静默忽略选项。 +界面显示服务端返回的名称、版本和连接安全状态。非本机地址使用明文 HTTP 时先显示普通风险警告,并在连接期间持续标记“不安全”;HTTPS 证书错误不能静默忽略,也不能自动降级为 HTTP。 ### 2. 登录或注册 @@ -60,20 +59,23 @@ Access Token、Refresh Token、设备私钥和同步密钥不进入普通设置 - device UUID 代表本次 NoteGen 安装而不是登录会话。断开服务器、退出账号或重新关联时必须保留;只有本地设备身份或私钥确实丢失时才能生成新 UUID。 - 登录密码与高级 E2EE 同步口令必须是两个概念。默认连接不要求同步口令;用户主动开启高级端到端加密时,登录密码不能复用为同步口令。 -### 3. 自动绑定当前笔记目录 +### 3. 创建个人数据空间并显式绑定资料库 -登录成功后不展示 Workspace 选择,客户端直接调用 `POST /v1/workspaces/default`: +登录成功后,客户端幂等取得账号唯一的 `account-data` Workspace,用于 tags、marks、conversation、message、memory 与可同步 settings。该空间不可邀请其他成员,也不对应本地笔记目录。 -- 账号还没有默认同步空间时,服务端原子创建并保存托管密钥 envelope。 -- 已存在时返回同一个内部同步空间,避免重复创建。 -- 客户端将当前 NoteGen 笔记目录标识与该连接保存在本机,随后自动设为主要同步方式。 -- 本地目录发生变化时暂停同步,防止将两个资料库意外混入同一远端空间;用户在新目录重新连接后才重新绑定。 +每个本地笔记目录单独对应一个 `library` Workspace。首次发现未绑定目录时,客户端必须让用户选择: -内部同步空间 ID 只用于协议和数据隔离,不出现在普通设置界面。不同设备的绝对路径可以不同,它们通过同一账号的默认同步空间关联,不能把本机绝对路径作为服务端数据主键。 +- 新建资料库并绑定当前目录。 +- 从自己拥有或加入的资料库中选择一个,并选择独立的本地落地目录。 +- 暂不同步。 + +禁止静默上传未绑定目录。加入他人的资料库不会替换当前目录,而是在资料库列表中增加一个独立 Workspace;多个资料库可以同时同步到不同的本地目录。 + +Workspace ID 用于协议、权限和数据隔离。不同设备的绝对路径可以不同,本机绝对路径不得作为服务端数据主键。 默认托管模式追求零配置,登录后的新设备可以自动解锁。用户在“高级加密”中设置独立同步口令后,客户端把同一密钥改包为 passphrase + recovery envelope,服务端事务性删除 managed envelope。恢复密钥只展示一次;此后新设备必须使用同步口令或恢复密钥,服务端无法找回明文。 -旧版本创建的 E2EE 默认空间不能阻塞普通同步。若客户端资料未标记为用户主动启用高级 E2EE,服务端保留旧空间及其密文、取消其默认标记,并创建新的托管默认空间;客户端随后把当前本地 Markdown 快照自动同步到新空间。只有用户明确选择恢复旧 E2EE 数据时才要求口令或恢复密钥。 +protocol v1 只开放 managed 加密;信封和 key version 保留未来 E2EE 扩展能力,但客户端不显示尚未实现的 E2EE 开关。 ### 4. 自动首次同步 diff --git a/docs/operations.md b/docs/operations.md index b83a1d9..2f866ef 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -27,11 +27,17 @@ curl --fail http://localhost:3789/health/ready `pnpm dev` 会先执行全部 Drizzle migration,成功后才启动 HTTP 服务。 -首次打开 Web 页面会进入安装向导。向导负责选择自托管或运营模式、设置实例名称与注册策略,并在同一事务中创建首位管理员:自托管创建客户域的实例管理员,运营模式创建独立 Staff 域的运营管理员。保存后服务会自动切换到完整运行状态,部署模式由数据库唯一决定,不再读取 `DEPLOYMENT_MODE`。 +首次打开 Web 页面会进入安装向导。向导负责设置实例名称,并在同一事务中创建首位实例管理员。保存后服务会自动切换到完整运行状态。官方公共测试服务与用户自行部署使用同一套独立实例运行方式。 实例名称、对象与附件大小上限、同步数据保留期、邮件默认语言、账号删除周期和完整 SMTP 配置由管理员在 Web 后台“实例运维”中维护,保存后立即生效。SMTP 密码使用 `AUTH_SECRET` 派生密钥加密保存,接口只返回是否已配置,不会回显。 -数据库连接、公开 URL 和认证密钥属于启动配置,继续保留在 env。部署模式与运营模式的内部测试适配器由安装向导固化,普通部署不需要理解相关环境变量。 +数据库连接、公开 URL 和认证密钥属于启动配置,继续保留在 env。注册策略、邀请和可选 SMTP 由实例管理员在 Web 后台维护。 + +## 公共测试实例边界 + +官方公共实例与自行部署使用相同程序,不加载计费、订阅、Staff 运营后台或商业客服能力。它仅用于免费测试和社区体验,不承诺商业 SLA,也不应作为用户数据的唯一备份。 + +实例管理员仍需承担最低限度的维护工作:保护管理员凭据、默认关闭或限制注册、处理明显滥用、设置合理的上传与存储上限、监控磁盘容量,并定期备份 PostgreSQL 与 Blob 数据。SMTP 是可选项;未配置邮件时,管理员可以直接复制一次性邀请链接给用户。 `PUBLIC_BASE_URL` 必须是客户端实际访问的 HTTPS 地址。首次启动会在 PostgreSQL 中生成稳定的 `instanceId`;它包含在数据库备份中,客户端用它识别服务器是否被意外重置。 @@ -41,9 +47,7 @@ curl --fail http://localhost:3789/health/ready ## 首次安装 -空数据库启动后只装配安装页面、健康检查和安装 API。访问 Web 页面,选择部署模式并完成向导。服务会在安装响应结束后自动关闭安装态监听、重新读取数据库配置并装配完整服务;页面检测到就绪后自动进入对应入口。自托管模式创建的管理员从普通 Web 入口登录;运营模式创建的运营管理员从 `/operations/` 登录,凭据、会话、角色和权限均不写入客户 `accounts`。客户公开注册只创建普通客户账号,不能进入运营后台。空实例不要求额外安装密钥,因此应先完成安装,再开放公网入口。 - -当前运营模式属于内部测试阶段,因此安装向导创建本地运营账号,并授予首个账号 `platform-admin` 角色。该角色通过 Staff 权限表授权,不等同于客户管理员。正式运营接入 OIDC/SSO 后,应沿用同一 Staff principal、role 和 session 边界;本地密码登录不作为生产身份方案。 +空数据库启动后只装配安装页面、健康检查和安装 API。访问 Web 页面并完成向导。服务会在安装响应结束后自动关闭安装态监听、重新读取数据库配置并装配完整服务;页面检测到就绪后自动进入普通登录入口。空实例不要求额外安装密钥,因此应先完成安装,再开放公网入口。 系统管理员可以在 Web 后台的“系统管理”中授予或移除其他账号的管理员权限、停用或恢复账号,并查看后台操作审计。停用使用独立的 `suspended_at` 状态,不会进入账号删除保留期;账号申请删除仍由 `disabled_at` 和维护任务处理。系统会阻止停用、降级或删除最后一个可用管理员。 @@ -55,7 +59,7 @@ curl --fail http://localhost:3789/health/ready 生产构建中,服务端可在根路径提供账号管理 Web;本地开发时 Web 默认运行在 `3790` 端口。若设置 `WEB_ENABLED=false`,只关闭静态页面,不影响账号密码登录和同步 API。 -自托管和运营模式在首次 Web 安装时选择。该选择会固化到 `deployment_settings`,当前数据库不支持原地切换;需要测试另一种模式时应使用新的空数据库。同步协议、镜像和数据库结构不分叉。 +服务只维护独立实例这一种运行方式,不区分官方服务与自行部署。同步协议、镜像和数据库结构也不分叉。历史运营模式数据库不会自动转换,需要先迁移业务数据或使用新的空数据库。 ## 反向代理 @@ -149,6 +153,8 @@ pnpm --filter @notegen/server maintenance ## 上线检查 +完整的执行顺序和责任边界见 [公共测试实例上线清单](production-checklist.md)。 + - `/v1/capabilities` 中的 `publicBaseUrl`、`serverName` 和协议范围正确。 - 外部 HTTPS 与 WSS 均可用,证书链完整。 - `AUTH_SECRET`、数据库密码和 S3 凭据来自秘密管理,不写入版本控制文件。 diff --git a/docs/production-checklist.md b/docs/production-checklist.md new file mode 100644 index 0000000..9fb2e7c --- /dev/null +++ b/docs/production-checklist.md @@ -0,0 +1,69 @@ +# NoteGen 公共测试实例上线清单 + +这份清单适用于官方公共测试实例,也可作为自行部署的生产前检查。项目免费且不提供商业 SLA,但仍需要满足最低安全、恢复和滥用控制要求。 + +## 1. 首次安装 + +- [ ] 使用空数据库启动,确认 Web 自动进入安装向导。 +- [ ] 设置清晰的实例名称并创建唯一的首位管理员。 +- [ ] 管理员使用独立强密码登录,并启用 TOTP。 +- [ ] 完成安装后重新访问 `/v1/installation/status`,确认安装入口已经关闭。 +- [ ] 默认注册策略设置为“仅邀请”或“关闭”。 + +## 2. 域名与网络 + +- [ ] 为后台准备独立二级域名,例如 `sync.notegen.top`。 +- [ ] `PUBLIC_BASE_URL` 与用户实际访问的 HTTPS origin 完全一致,不包含路径。 +- [ ] HTTPS 证书链完整,并设置自动续期。 +- [ ] 反向代理支持 WebSocket Upgrade、大附件请求体和 Range 下载。 +- [ ] 外网无法绕过反向代理直接访问服务端口。 +- [ ] 仅在代理网络可信时启用 `TRUST_PROXY=true`。 + +## 3. 密钥与权限 + +- [ ] `AUTH_SECRET` 使用至少 32 字节的随机值,保存在秘密管理或受限环境文件中。 +- [ ] PostgreSQL、S3 和 SMTP 使用独立最小权限凭据。 +- [ ] `.env`、数据库密码、SMTP 密码和备份密钥没有进入版本控制、日志或截图。 +- [ ] 至少保留一种管理员恢复路径,并确认不会重新开放普通注册。 + +## 4. 容量与滥用控制 + +- [ ] 配置合理的单对象、单附件和请求体大小上限。 +- [ ] 确认登录、注册、设备授权和上传接口具有限流。 +- [ ] 为数据库、Blob 存储和备份介质设置容量告警。 +- [ ] 明确异常账号停用、邀请撤销和明显滥用数据的处理方式。 +- [ ] 公开注册仅用于短时测试;没有持续维护能力时保持“仅邀请”。 + +## 5. 备份与恢复 + +- [ ] PostgreSQL 与 Blob 数据在同一恢复点配套备份。 +- [ ] 备份副本与运行实例分离,访问权限独立。 +- [ ] 完成至少一次隔离环境恢复,确认账号、实例 ID、附件和同步数据一致。 +- [ ] 记录最近成功备份、最近恢复演练时间和负责人。 +- [ ] 向用户明确公共测试实例不是唯一备份,NoteGen 本地数据必须保留。 + +## 6. 邮件 + +- [ ] 不需要邮件时保持 SMTP 关闭;登录、设备授权和手动邀请仍应正常工作。 +- [ ] 启用 SMTP 时配置正确的发件地址、TLS 和连接超时。 +- [ ] 发送测试邮件,确认队列、重试和脱敏日志符合预期。 +- [ ] 邮件不可用不会阻断同步服务的健康状态。 + +## 7. 监控与验收 + +- [ ] `/health/live` 与 `/health/ready` 由外部监控定期检查。 +- [ ] 需要 Prometheus 时启用 `/metrics`,并限制到受信网络或配置 Bearer Token。 +- [ ] 日志和磁盘容量具备告警;日志不包含 token、密码或同步密文载荷。 +- [ ] 临时启用公开注册并执行 `pnpm test:self`,成功后恢复原注册策略。 +- [ ] 使用两台真实 NoteGen 设备验证登录、设备关联、同步、断线恢复和设备撤销。 + +## 8. 对外说明 + +- [ ] 登录页可以访问 `/service/` 服务说明。 +- [ ] 用户知道服务免费、不提供商业 SLA,并可能因维护或资源限制调整。 +- [ ] 用户知道默认托管密钥模式和高级端到端加密模式的差异。 +- [ ] 提供账号停用和数据删除入口,并在官网或服务说明中给出维护者认可的反馈渠道。 + +## 尚需部署者独立完成 + +仓库无法代替部署者完成真实域名解析、证书签发、生产密钥生成、服务器防火墙、异地备份介质、恢复演练和监控告警接收。这些项目全部确认后,公共测试实例才适合对外开放。 diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md new file mode 100644 index 0000000..aea0ae9 --- /dev/null +++ b/docs/protocol-v1.md @@ -0,0 +1,71 @@ +# NoteGen Self-hosted Sync Protocol v1 + +Status: frozen for the first NoteGen client integration. + +The runtime contract is the OpenAPI document served by `GET /openapi.json`. Route schemas must be changed before generated clients or this document. Protocol v1 does not preserve compatibility with the unpublished pre-v1 draft. + +## Workspace model + +- `account-data`: one idempotently-created, non-shareable personal workspace per account. It contains tags, marks, conversation metadata, independent messages, memories, portable settings, and their attachments. +- `library`: a note directory containing folders, Markdown notes, assets, canvases, Yjs updates, and checkpoints. A local directory binds to exactly one library; the same library can bind to different local paths on different devices. + +Libraries have an owner plus optional members. Roles (`viewer`, `editor`, `manager`) are templates over the fixed capability set; invitations and member updates may submit a capability subset. Owners always retain every capability. Account-data workspaces cannot be invited to or shared. + +## Durable loop + +Every sync write includes `expectedSyncEpoch`. A client runs: + +```text +GET sync/session +→ fixed snapshot bootstrap when required +→ POST durable commands +→ GET ordered events +→ persist and apply inbox +→ POST ack +→ connect WebSocket for wake-up and presence +``` + +`commandId` is an immutable idempotency key. The same ID may only be retried with the same payload. Cursor advancement happens only after the inbox is durable and successfully applied. WebSocket messages never advance the cursor. + +`GET /v1/workspaces/:id/sync/session` negotiates protocol v1 and returns cursor state, latest sequence, bootstrap requirement, limits, key versions, current permissions, sync epoch, and the WebSocket URL. + +## Object and collaboration model + +Object kinds are `folder`, `note`, `asset`, `canvas`, `tag`, `mark`, `conversation`, `message`, `memory`, `setting`, `yjs-update`, and `yjs-checkpoint`. The unpublished `record` kind is not part of v1. + +Markdown uses a portable encrypted snapshot plus durable encrypted Yjs updates. Canvas state uses Yjs maps for nodes and edges and periodically materializes ordinary Canvas JSON. `append-update` and `commit-checkpoint` are HTTP commands. The server commits the update and event transaction before publishing `workspace.changed`; it never accepts an update payload over WebSocket. + +A checkpoint and its materialized object revision must both be acknowledged before clients discard covered local updates. Source mode, large-document mode, and external file changes use snapshot three-way merge and create a local conflict copy when no safe merge exists. + +## WebSocket + +The first frame is: + +```json +{ + "type": "authenticate", + "accessToken": "...", + "workspaceIds": ["..."], + "expectedSyncEpoch": "..." +} +``` + +After `authenticated`, clients may send `document.subscribe`, `document.unsubscribe`, `presence.update`, and `presence.clear`. Server messages include durable-change wake-ups, document sync requests, presence, membership changes, key changes, workspace state, and access revocation. Presence is ephemeral and is never written to the sync event log. + +`presence.update` always carries the active document and selection anchor/head. For Canvas, it may additionally carry `canvas.nodes` with at most 100 `{ id, x, y }` positions. Drag positions are ephemeral; the drag-stop state must still be persisted through an encrypted durable Yjs command. + +## Managed encryption v1 + +The first release uses managed workspace keys. Content remains encrypted in object and Blob storage; the server retains a managed envelope so a newly authorized device can recover the key. Payload encryption is XChaCha20-Poly1305 with a random 24-byte nonce. Device key interfaces use X25519 and HKDF-SHA256, and Argon2id APIs are reserved for later user-controlled E2EE. + +Tokens, API keys, device private keys, local paths, sync profiles, caches, thumbnails, embeddings, vector indexes, activity statistics, and hardware choices are never synchronized. + +## Bootstrap and conflicts + +The first bootstrap request omits `bootstrapId`; subsequent pages repeat the returned ID and `afterObjectId`. All pages belong to one `snapshotSequence`. The client stages and applies the snapshot, ACKs the snapshot sequence, then pulls later events. It never offers an entire-library overwrite. + +Same-path divergent content produces a conflict copy. A viewer's local modification becomes a permission conflict and can be exported or moved to a personal library. Membership removal stops the runtime immediately, deletes that workspace's locally stored credentials, and leaves downloaded files as a local read-only/exportable copy. + +## Transport security + +HTTPS never downgrades automatically. NoteGen may connect to a non-local HTTP instance only after displaying a clear warning, and it keeps the connection marked insecure. Browser device authorization is the default login; password and optional TOTP are fallback methods. diff --git a/docs/self-test.md b/docs/self-test.md index 49ed6e1..2dbb40a 100644 --- a/docs/self-test.md +++ b/docs/self-test.md @@ -9,7 +9,7 @@ 1. 启动 `note-gen-server`,确认 `/health/ready` 返回 200。 2. 打开 NoteGen → 设置 → 同步 → NoteGen 同步服务器。 3. 本机部署填写 `http://127.0.0.1:3789`;远程部署填写实际 HTTPS 域名。 -4. 选择“注册”或“登录”。封闭注册时,注册还要填写服务端 `.env` 中的 `SETUP_TOKEN`。 +4. 选择“注册”或“登录”。实例关闭注册时,由管理员切换为“仅邀请”并创建一次性邀请链接。 5. 登录密码至少 8 个字符。它只负责账号认证,并继续支持更长密码。 6. 登录完成后不需要选择 Workspace 或输入同步口令;NoteGen 自动绑定当前笔记目录并将 NoteGen Server 设为主要同步方式。 7. 确认页面显示“实时同步已启用”,并且当前笔记目录正确;页面不应出现上传、下载或立即同步按钮。 @@ -47,7 +47,7 @@ - Node.js 22 或更高版本。 - 已在项目目录执行 `pnpm install`。 - 服务端已启动,且 `/health/ready` 返回 200。 -- 封闭注册模式下,知道当前部署的 `SETUP_TOKEN`。 +- 管理员已在 Web 后台临时启用公开注册;脚本结束后应恢复为“仅邀请”或“关闭”。 本地 PostgreSQL 部署可以先执行: @@ -55,7 +55,7 @@ createuser notegen createdb -O notegen notegen cp .env.example .env -# 编辑 .env,至少设置 POSTGRES_PASSWORD、DATABASE_URL、AUTH_SECRET、SETUP_TOKEN 和 PUBLIC_BASE_URL +# 编辑 .env,至少设置数据库密码、DATABASE_URL、AUTH_SECRET 和 PUBLIC_BASE_URL pnpm install pnpm dev curl --fail http://127.0.0.1:3789/health/ready @@ -63,13 +63,7 @@ curl --fail http://127.0.0.1:3789/health/ready ## 本机使用 -服务端使用默认地址、并且为封闭注册时: - -```bash -SELF_TEST_SETUP_TOKEN='你的 SETUP_TOKEN' pnpm test:self -``` - -开放注册模式无需 Setup Token: +在 Web 后台临时启用公开注册后运行: ```bash pnpm test:self @@ -89,18 +83,16 @@ pnpm test:self ```bash SELF_TEST_BASE_URL='https://sync.example.com' \ -SELF_TEST_SETUP_TOKEN='你的 SETUP_TOKEN' \ pnpm test:self ``` -这也会验证反向代理的 HTTPS API 和 WSS WebSocket 转发。不要在命令历史、CI 日志或截图中公开 Setup Token。 +这也会验证反向代理的 HTTPS API 和 WSS WebSocket 转发。验收结束后立即恢复原注册策略。 ## 保留测试数据 需要登录测试账号、手工检查 Workspace 时: ```bash -SELF_TEST_SETUP_TOKEN='你的 SETUP_TOKEN' \ SELF_TEST_KEEP_DATA=true \ pnpm test:self ``` @@ -112,7 +104,7 @@ pnpm test:self | 现象 | 检查项 | |---|---| | `fetch failed` | 地址、端口、防火墙、HTTPS 证书和服务进程状态 | -| `registration_closed` | 是否传入与服务端一致的 `SELF_TEST_SETUP_TOKEN` | +| 自动验收提示需启用公开注册 | 在 Web 后台临时将注册策略改为“公开”,完成后恢复原策略 | | `/health/ready` 返回 503 | PostgreSQL、Blob 目录/S3 和 migration 日志 | | WebSocket 超时 | 反向代理是否支持 Upgrade,是否对事件接口启用了缓冲 | | `instanceId` 与客户端记录不一致 | 数据库是否被重置、恢复错备份或指向了另一套部署 | @@ -130,3 +122,4 @@ pnpm dev:server - [本地运行与运维](operations.md) - [NoteGen 客户端接入协议](client-protocol.md) - [NoteGen 接入与配置同步体验](notegen-integration.md) +- [公共测试实例上线清单](production-checklist.md) diff --git a/docs/sync-server-spec.md b/docs/sync-server-spec.md index 2c51e4c..872a114 100644 --- a/docs/sync-server-spec.md +++ b/docs/sync-server-spec.md @@ -5,6 +5,8 @@ - 面向读者:NoteGen 客户端与同步服务开发者、部署维护者 - 目标:确定第一版可实施的同步协议、服务端边界、数据模型、加密模型与交付顺序 +> 本文保留早期设计背景。已经冻结的发布协议以 [protocol-v1.md](protocol-v1.md) 与 `/openapi.json` 为准;其中“无多人共享/无成员/无 presence”等早期非目标已经被 protocol v1 取代。 + ## 1. 概述 NoteGen Sync Server 是一个无界面、可 Docker 自托管的同步服务。NoteGen 桌面端、移动端和 Web 端均作为本地优先客户端,通过同一套协议与服务端交换变更。 diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index baa1341..1891bc5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -143,7 +143,7 @@ export interface AdminCapabilityContract { } export interface AdminCapabilitiesContract { - deploymentMode: "self-hosted" | "hosted" + deploymentMode: "self-hosted" generatedAt: string capabilities: AdminCapabilityContract[] } @@ -256,6 +256,9 @@ export interface DeviceContract { createdAt: string revokedAt: string | null current: boolean + syncStatus: "caught-up" | "behind" | "never-acknowledged" + pendingEventCount: string + acknowledgedAt: string | null } export interface SyncOverviewContract { @@ -268,6 +271,13 @@ export interface SyncOverviewContract { latestSequence: string lastActivityAt: string | null encryptionMode: "managed" | "e2ee" | "mixed" | null + storageUsage: { + activeObjectBytes: string + activeCrdtBytes: string + activeBlobBytes: string + reservedBlobBytes: string + retainedBytes: string + } | null kinds: Array<{ kind: string activeCount: number @@ -288,8 +298,45 @@ export interface SyncOverviewContract { } export type SyncObjectKindContract = - | "note" | "folder" | "asset" | "canvas" | "record" | "tag" | "mark" - | "conversation" | "memory" | "setting" | "yjs-checkpoint" | "yjs-update" + | "note" | "folder" | "asset" | "canvas" | "tag" | "mark" + | "conversation" | "message" | "memory" | "setting" | "yjs-checkpoint" | "yjs-update" + +export type WorkspaceTypeContract = "account-data" | "library" +export type WorkspaceRoleContract = "owner" | "viewer" | "editor" | "manager" +export type WorkspaceCapabilityContract = + | "content.read" | "content.create" | "content.update" | "content.delete" + | "history.view" | "history.restore" + | "member.invite" | "member.update" | "member.remove" + | "workspace.rename" | "workspace.delete" + +export interface SyncSessionContract { + protocol: { requestedVersion: number, selectedVersion: 1, compatible: boolean } + workspace: { + id: string + type: WorkspaceTypeContract + role: WorkspaceRoleContract + owner: boolean + capabilities: WorkspaceCapabilityContract[] + } + cursor: { + supplied: string + state: "valid" | "ahead" | "expired" + acknowledged: string + oldestAvailableSequence: string | null + } + latestSequence: string + bootstrap: { required: boolean, reason: "cursor_ahead" | "cursor_expired" | null } + limits: { + maxCommandsPerBatch: number + maxEventsPerPage: number + maxBootstrapObjectsPerPage: number + maxDocumentUpdatesPerPage: number + maxObjectBytes: number + } + keyVersions: Array<{ keyVersion: number, createdAt: string }> + syncEpoch: string + websocketUrl: string +} export interface WebWorkspaceContract { id: string @@ -375,7 +422,7 @@ export interface ServerCapabilitiesContract { emailVerificationRequired: boolean } instanceCapabilities: Record - deploymentMode: "self-hosted" | "hosted" + deploymentMode: "self-hosted" web: { accountUrl: string deviceAuthorizationUrl: string From 8b31c19edbc8349256526b7f252bc2671d73e226 Mon Sep 17 00:00:00 2001 From: codexu <461229187@qq.com> Date: Sun, 23 Aug 2026 15:11:48 +0800 Subject: [PATCH 2/2] feat: add Docker support and CI for NoteGen Server - Introduced a Dockerfile for building the NoteGen Server image. - Added Docker Compose files for local development and production deployment. - Created a GitHub Actions workflow for publishing multi-architecture container images to GitHub Container Registry. - Added a sample environment configuration file for Docker. - Implemented a new chart component in the web application for better data visualization. - Documented the deployment process and container publishing guidelines. - Added database migration scripts to clean up invalid workspace objects. - Established contribution guidelines for developers. --- .dockerignore | 4 + .env.docker.example | 25 ++ .github/workflows/publish-container.yml | 76 ++++ .gitignore | 1 + CONTRIBUTING.md | 73 ++++ Dockerfile | 66 ++++ README.md | 38 ++ .../0005_workspace_object_boundaries.sql | 14 + apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/auth/service.ts | 38 +- apps/server/src/durable-sync/service.ts | 118 +++++- apps/server/src/durable-sync/types.ts | 8 + apps/server/src/routes/events.ts | 11 + apps/server/src/routes/sync.ts | 15 +- apps/server/src/routes/web-dashboard.ts | 29 +- apps/server/src/workspaces/service.ts | 133 +++++-- apps/web/app/globals.css | 20 + apps/web/components/account-portal.tsx | 306 ++++++++++---- apps/web/components/ui/chart.tsx | 373 ++++++++++++++++++ apps/web/package.json | 1 + compose.dev.yaml | 6 + compose.yaml | 55 +++ docs/client-protocol.md | 10 +- docs/deployment/container-publishing.md | 43 ++ docs/deployment/docker.md | 111 ++++++ docs/protocol-v1.md | 10 +- packages/contracts/src/index.ts | 28 +- pnpm-lock.yaml | 298 +++++++++++++- 28 files changed, 1755 insertions(+), 162 deletions(-) create mode 100644 .env.docker.example create mode 100644 .github/workflows/publish-container.yml create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 apps/server/drizzle/0005_workspace_object_boundaries.sql create mode 100644 apps/web/components/ui/chart.tsx create mode 100644 compose.dev.yaml create mode 100644 compose.yaml create mode 100644 docs/deployment/container-publishing.md create mode 100644 docs/deployment/docker.md diff --git a/.dockerignore b/.dockerignore index c21baed..39c3f05 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,11 @@ dist out .turbo .git +.github .env +.env.* +!.env.example +!.env.docker.example coverage data *.log diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..ead9a7e --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,25 @@ +# 实验版本默认跟随仓库主开发分支。需要固定版本时改为对应的版本标签。 +NOTEGEN_SERVER_IMAGE=ghcr.io/codexu/note-gen-server:edge + +# PostgreSQL 密码。推荐使用 `openssl rand -hex 32` 生成,避免 URL 保留字符。 +POSTGRES_PASSWORD=replace-with-a-random-hex-password + +# 登录令牌和数据库内敏感配置的加密密钥。上线后不要随意更换。 +AUTH_SECRET=replace-with-at-least-32-random-characters + +# NoteGen 客户端实际访问的公开地址,不要带路径或末尾斜杠。 +# 本机实验可以使用 http://127.0.0.1:3789;远程部署必须使用 HTTPS 域名。 +PUBLIC_BASE_URL=http://127.0.0.1:3789 + +# 默认只监听宿主机回环地址,由 Caddy/Nginx/Traefik 反向代理。 +# 仅在受信局域网实验时改为 0.0.0.0,并同时配置防火墙。 +SERVER_BIND_ADDRESS=127.0.0.1 +SERVER_PORT=3789 + +# 只有外部无法绕过受信反向代理直连服务端口时才设置为 true。 +TRUST_PROXY=false + +LOG_LEVEL=info +METRICS_ENABLED=false +METRICS_TOKEN= +OPENAPI_ENABLED=false diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml new file mode 100644 index 0000000..d44ecb0 --- /dev/null +++ b/.github/workflows/publish-container.yml @@ -0,0 +1,76 @@ +name: Publish container image + +on: + push: + branches: + - feat/sync-foundation + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/note-gen-server + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=edge,enable=${{ github.ref == 'refs/heads/feat/sync-foundation' }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + labels: | + org.opencontainers.image.title=NoteGen Server + org.opencontainers.image.description=Self-hosted synchronization server and account portal for NoteGen + org.opencontainers.image.source=https://github.com/${{ github.repository }} + + - name: Build and push image + id: push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/.gitignore b/.gitignore index f206946..bcbf237 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ out/ .env .env.* !.env.example +!.env.docker.example coverage/ data/ *.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7a4c226 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,73 @@ +# 参与 NoteGen Server 开发 + +NoteGen Server 处于实验阶段,欢迎通过 Issue、文档改进和代码贡献共同完善部署、同步协议、服务端 API 与账号管理 Web。 + +## 选择运行方式 + +### 源码开发 + +适合频繁修改、调试和提交代码。需要 Node.js 22 或更高版本、pnpm 10.20.0 和 PostgreSQL 17。 + +```bash +git clone https://github.com/codexu/note-gen-server.git +cd note-gen-server +pnpm install +cp .env.example .env +pnpm dev +``` + +首次创建本地数据库: + +```bash +createuser notegen +createdb -O notegen notegen +``` + +修改 `.env` 中的 `DATABASE_URL`、`AUTH_SECRET` 和 `PUBLIC_BASE_URL`。API 默认监听 `3789`,开发态 Web 默认监听 `3790`。 + +### 本地容器构建 + +适合复现 Docker 部署问题或验证镜像构建: + +```bash +cp .env.docker.example .env +docker compose -f compose.yaml -f compose.dev.yaml up -d --build +``` + +完整说明见 [Docker Compose 部署](docs/deployment/docker.md)。 + +## 仓库结构 + +```text +apps/server Fastify API、认证、同步和运维 CLI +apps/web 静态导出的账号管理 Web +packages/contracts Web 与服务端共享的协议类型 +docs 协议、部署和运维文档 +``` + +## 修改边界 + +- 数据库结构变化必须通过 Drizzle migration 表达,不手工改 migration journal。 +- 同步协议和 capabilities 采用显式版本与能力协商,不能只根据服务端版本猜测行为。 +- 不把访问令牌、密码、同步密文、封装密钥或真实 `.env` 提交到仓库。 +- Web 使用静态导出,生产环境由 Fastify 同源托管。 +- 新配置项需要同步更新 `.env.example`、部署文档和能力说明。 + +## 提交前检查 + +项目当前提供以下检查命令,按改动范围选择执行: + +```bash +pnpm typecheck +pnpm test +pnpm build +pnpm db:migrate +``` + +涉及真实同步链路时,可在服务启动后运行: + +```bash +INTEGRATION_BASE_URL=http://127.0.0.1:3789 pnpm test:integration +``` + +提交 Pull Request 时请说明问题、实现方式、兼容性影响,以及自己执行过的验证。涉及数据库迁移、协议、安全边界或备份恢复时,同时说明升级与回退约束。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e6ed7e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,66 @@ +FROM node:24-bookworm-slim AS base + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN apt-get update \ + && apt-get install -y --no-install-recommends g++ make python3 \ + && rm -rf /var/lib/apt/lists/* \ + && corepack enable \ + && corepack prepare pnpm@10.20.0 --activate + +WORKDIR /app + +FROM base AS dependencies + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/contracts/package.json packages/contracts/package.json + +RUN pnpm install --frozen-lockfile + +FROM dependencies AS builder + +COPY . . + +RUN pnpm build + +FROM base AS production-dependencies + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/server/package.json apps/server/package.json +COPY apps/web/package.json apps/web/package.json +COPY packages/contracts/package.json packages/contracts/package.json + +RUN pnpm install --prod --frozen-lockfile + +FROM node:24-bookworm-slim AS runner + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3789 + +LABEL org.opencontainers.image.source="https://github.com/codexu/note-gen-server" +LABEL org.opencontainers.image.description="Self-hosted synchronization server and account portal for NoteGen" + +WORKDIR /app + +COPY --from=production-dependencies /app/node_modules ./node_modules +COPY --from=production-dependencies /app/apps/server/node_modules ./apps/server/node_modules +COPY --from=builder /app/apps/server/package.json ./apps/server/package.json +COPY --from=builder /app/apps/server/dist ./apps/server/dist +COPY --from=builder /app/apps/server/drizzle ./apps/server/drizzle +COPY --from=builder /app/apps/web/out ./apps/web/out + +RUN mkdir -p /app/data/blobs /app/data/backups \ + && chown -R node:node /app + +USER node + +EXPOSE 3789 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3789/health/ready').then((response) => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1))"] + +CMD ["node", "apps/server/dist/entrypoint.js"] diff --git a/README.md b/README.md index a32b4d3..5d3c0c0 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,43 @@ NoteGen Sync Server 是 NoteGen 客户端使用的免费同步服务。官方公共测试服务和用户自行部署使用同一套独立实例运行方式。 +> 自托管服务目前处于实验阶段,部署方式、配置和数据库结构仍可能变化。请保留 NoteGen 本地 Markdown,不要把实验实例作为重要数据的唯一副本。 + 项目包含同步 API 和账号管理 Web。Web 负责注册、登录、设备关联、同步统计和账号管理,不包含 Markdown 编辑器、AI 功能或多人协作功能。 +## 开始使用 + +### Docker Compose(推荐体验) + +适合快速搭建个人测试实例。官方 Compose 默认拉取 GHCR 的 `edge` 镜像,并启动 NoteGen Server 与 PostgreSQL 17: + +```bash +git clone https://github.com/codexu/note-gen-server.git +cd note-gen-server +cp .env.docker.example .env +# 编辑 .env,设置 POSTGRES_PASSWORD、AUTH_SECRET 和 PUBLIC_BASE_URL +docker compose up -d +docker compose ps +``` + +详细的首次初始化、HTTPS、日志和数据说明见 [Docker Compose 部署](docs/deployment/docker.md)。 + +### 从源码运行(开发者) + +适合调试服务端、修改功能和提交贡献。需要 Node.js 22 或更高版本、pnpm 10.20.0 和 PostgreSQL 17: + +```bash +git clone https://github.com/codexu/note-gen-server.git +cd note-gen-server +createuser notegen +createdb -O notegen notegen +pnpm install +cp .env.example .env +pnpm dev +``` + +贡献约定、仓库结构与本地容器构建方法见 [CONTRIBUTING.md](CONTRIBUTING.md)。 + ## 项目定位 - 项目免费且不以商业化为目标,不提供付费订阅、套餐或商业 SLA。 @@ -23,6 +58,9 @@ apps/web Next.js + shadcn/ui 账号管理页面 当前项目已完成首个可部署的服务端基线,详细方案见: +- [Docker Compose 部署](docs/deployment/docker.md) +- [容器镜像发布](docs/deployment/container-publishing.md) +- [参与服务端开发](CONTRIBUTING.md) - [同步服务技术规格](docs/sync-server-spec.md) - [本地运行与运维](docs/operations.md) - [NoteGen 客户端接入协议](docs/client-protocol.md) diff --git a/apps/server/drizzle/0005_workspace_object_boundaries.sql b/apps/server/drizzle/0005_workspace_object_boundaries.sql new file mode 100644 index 0000000..c142551 --- /dev/null +++ b/apps/server/drizzle/0005_workspace_object_boundaries.sql @@ -0,0 +1,14 @@ +-- Remove current objects written into the wrong workspace type by unpublished +-- protocol drafts. Immutable object versions remain available as retained +-- history, but the invalid objects no longer participate in bootstrap or +-- current-content accounting. +DELETE FROM "objects" AS "o" +USING "workspaces" AS "w" +WHERE "o"."workspace_id" = "w"."id" + AND ( + ("w"."workspace_type" = 'account-data' AND "o"."kind" IN ('note', 'folder')) + OR ( + "w"."workspace_type" = 'library' + AND "o"."kind" IN ('canvas', 'tag', 'mark', 'record', 'conversation', 'message', 'memory', 'setting') + ) + ); diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index b1ab09d..fd5ae42 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1787130000000, "tag": "0004_workspace_collaboration", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1787133600000, + "tag": "0005_workspace_object_boundaries", + "breakpoints": true } ] } diff --git a/apps/server/src/auth/service.ts b/apps/server/src/auth/service.ts index 5a9053e..0a942cf 100644 --- a/apps/server/src/auth/service.ts +++ b/apps/server/src/auth/service.ts @@ -1,7 +1,7 @@ import argon2 from 'argon2' import { and, eq, isNull, ne, sql } from 'drizzle-orm' import type { DatabaseContext } from '../database/client.js' -import { accountIdentities, accountLoginClaimConflicts, accountLoginClaims, accounts, deploymentSettings, devicePairings, devices, refreshTokens, syncDeviceCursors, workspaces } from '../database/schema.js' +import { accountIdentities, accountLoginClaimConflicts, accountLoginClaims, accounts, deploymentSettings, devicePairings, devices, refreshTokens, syncDeviceCursors, syncEvents, workspaces } from '../database/schema.js' import { ApiError } from '../errors.js' import { normalizeLoginKey } from '../identity/service.js' import type { TokenService } from './tokens.js' @@ -175,15 +175,11 @@ export class AuthService { if (existingDevice !== undefined && existingDevice.accountId !== accountId) { throw new ApiError({ code: 'device_conflict', message: 'Device belongs to another account', statusCode: 409 }) } - if (existingDevice?.encryptionPublicKey !== null && existingDevice?.encryptionPublicKey !== undefined - && input.encryptionPublicKey !== undefined - && input.encryptionPublicKey !== existingDevice.encryptionPublicKey) { - throw new ApiError({ - code: 'device_key_conflict', - message: 'Device ID is already bound to another encryption key', - statusCode: 409, - }) - } + // Re-authentication is authoritative proof that this account may reclaim + // its installation identity. This also lets an installation recover when + // its local private key was lost without creating another device record. + // #issueSession revokes every older refresh token for this device before + // issuing the replacement session. if (existingDevice === undefined || existingDevice.revokedAt !== null) { await this.usage?.admitDevice(tx, accountId, this.deviceLimitResolver === undefined ? null : await this.deviceLimitResolver(accountId)) @@ -314,7 +310,11 @@ export class AuthService { ...(refreshRequestId === undefined ? {} : { rotationRequestId: refreshRequestId, rotationResponseCiphertext: this.tokens.sealRefreshRecovery(JSON.stringify(session), `${stored.id}:${refreshRequestId}`), - rotationResponseExpiresAt: new Date(now.getTime() + 5 * 60_000), + // The request id is high entropy and proves this is the same refresh + // attempt. Keep its encrypted recovery response until the original + // token expires so a crash before local persistence cannot revoke the + // installation on its next launch. + rotationResponseExpiresAt: stored.expiresAt, }), }).where(eq(refreshTokens.id, stored.id)) await tx.insert(refreshTokens).values({ @@ -558,6 +558,12 @@ export class AuthService { acknowledgedSequence: syncDeviceCursors.acknowledgedSequence, latestSequence: workspaces.latestSequence, acknowledgedAt: syncDeviceCursors.updatedAt, + pendingObjectCount: sql`( + select count(distinct coalesce(${syncEvents.objectId}::text, ${syncEvents.eventId}::text)) + from ${syncEvents} + where ${syncEvents.workspaceId} = ${syncDeviceCursors.workspaceId} + and ${syncEvents.sequence} > ${syncDeviceCursors.acknowledgedSequence} + )`, }).from(syncDeviceCursors).innerJoin(workspaces, and( eq(workspaces.id, syncDeviceCursors.workspaceId), eq(workspaces.accountId, accountId), @@ -565,10 +571,10 @@ export class AuthService { )),]) return rows.map((device) => { const deviceCursors = cursors.filter(cursor => cursor.deviceId === device.id) - const pendingEventCount = deviceCursors.reduce((total, cursor) => ( - total + (cursor.latestSequence > cursor.acknowledgedSequence - ? cursor.latestSequence - cursor.acknowledgedSequence : 0n) - ), 0n) + const pendingEventCount = deviceCursors.reduce( + (total, cursor) => total + BigInt(cursor.pendingObjectCount), 0n, + ) + const isBehind = deviceCursors.some(cursor => cursor.latestSequence > cursor.acknowledgedSequence) const acknowledgedAt = deviceCursors.reduce((latest, cursor) => ( latest === null || cursor.acknowledgedAt > latest ? cursor.acknowledgedAt : latest ), null) @@ -576,7 +582,7 @@ export class AuthService { ...device, current: currentDeviceId !== undefined && device.id === currentDeviceId, syncStatus: deviceCursors.length === 0 ? 'never-acknowledged' as const - : pendingEventCount === 0n ? 'caught-up' as const : 'behind' as const, + : isBehind ? 'behind' as const : 'caught-up' as const, pendingEventCount: pendingEventCount.toString(), acknowledgedAt, } diff --git a/apps/server/src/durable-sync/service.ts b/apps/server/src/durable-sync/service.ts index 208eb64..902c290 100644 --- a/apps/server/src/durable-sync/service.ts +++ b/apps/server/src/durable-sync/service.ts @@ -8,7 +8,7 @@ import { } from '../database/schema.js' import { ApiError } from '../errors.js' import { assertAccountWriteAllowedInTransaction } from '../compliance/deletion-fence.js' -import type { ChangeNotifier } from '../sync/types.js' +import type { ChangeNotifier, SyncObjectKind } from '../sync/types.js' import type { WorkspaceService } from '../workspaces/service.js' import type { CiphertextEnvelope, SyncCommand, SyncCommandResult } from './types.js' import type { UsageService } from '../usage/service.js' @@ -18,8 +18,47 @@ const cacheableRejectionCodes = new Set([ 'request_invalid', 'object_too_large', 'ciphertext_hash_mismatch', 'object_kind_mismatch', 'update_id_reused', 'conflict_id_reused', 'document_identity_mismatch', 'asset_cannot_bind_resources', - 'name_blind_index_key_mismatch', + 'name_blind_index_key_mismatch', 'workspace_object_kind_invalid', ]) +// Library events materialize to the filesystem and require atomic writes and +// fsync. A long stream of intermediate snapshots is therefore much more +// expensive on mobile than structured account-data events. Switch libraries +// to a fixed bootstrap snapshot earlier so reconnecting devices catch up to +// the current tree instead of replaying thousands of obsolete file versions. +const maxLibraryIncrementalLag = 250n +const maxAccountDataIncrementalLag = 10_000n + +const accountDataObjectKinds = new Set([ + 'asset', 'canvas', 'tag', 'mark', 'conversation', 'message', 'memory', 'setting', + 'yjs-checkpoint', 'yjs-update', +]) +const libraryObjectKinds = new Set([ + 'asset', 'folder', 'note', 'yjs-checkpoint', 'yjs-update', +]) + +function assertCommandWorkspaceType( + workspaceType: 'account-data' | 'library', + command: SyncCommand, +): void { + const kind = command.type === 'upsert-object' + || command.type === 'initialize-document' + || command.type === 'append-update' + || command.type === 'commit-checkpoint' + || command.type === 'create-conflict' + ? command.kind + : command.type === 'resolve-conflict' + ? command.objectResolution?.kind ?? command.resolution?.kind + : undefined + if (kind === undefined) return + const allowed = workspaceType === 'account-data' ? accountDataObjectKinds : libraryObjectKinds + if (allowed.has(kind)) return + throw new ApiError({ + code: 'workspace_object_kind_invalid', + message: `Object kind ${kind} is not valid in a ${workspaceType} workspace`, + statusCode: 409, + details: { workspaceType, kind }, + }) +} function assertCommandCapability(capabilities: readonly WorkspaceCapability[], command: SyncCommand): void { let required: WorkspaceCapability @@ -34,6 +73,7 @@ function assertCommandCapability(capabilities: readonly WorkspaceCapability[], c case 'resolve-conflict': required = 'history.restore' break + case 'initialize-document': case 'append-update': case 'commit-checkpoint': case 'create-conflict': @@ -95,6 +135,19 @@ export class DurableSyncService { : oldestEvent !== undefined && cursor + 1n < oldestEvent.sequence ? 'expired' as const : 'valid' as const + const uninitializedDevice = deviceCursor === undefined && workspace.latestSequence > 0n + const maxIncrementalLag = access.type === 'library' + ? maxLibraryIncrementalLag + : maxAccountDataIncrementalLag + const lagTooLarge = workspace.latestSequence > cursor + && workspace.latestSequence - cursor > maxIncrementalLag + const bootstrapReason = cursorState === 'ahead' + ? 'cursor_ahead' as const + : cursorState === 'expired' + ? 'cursor_expired' as const + : uninitializedDevice + ? 'device_uninitialized' as const + : lagTooLarge ? 'lag_too_large' as const : null return { protocol: { requestedVersion: protocolVersion, @@ -116,8 +169,8 @@ export class DurableSyncService { }, latestSequence: workspace.latestSequence.toString(), bootstrap: { - required: cursorState !== 'valid', - reason: cursorState === 'valid' ? null : cursorState === 'ahead' ? 'cursor_ahead' : 'cursor_expired', + required: bootstrapReason !== null, + reason: bootstrapReason, }, limits: { maxCommandsPerBatch: 100, @@ -138,6 +191,7 @@ export class DurableSyncService { for (const command of commands) { try { assertCommandCapability(access.capabilities, command) + assertCommandWorkspaceType(access.type, command) results.push(await this.#command(access.ownerAccountId, deviceId, workspaceId, command)) } catch (error) { if (!(error instanceof ApiError)) throw error @@ -415,14 +469,42 @@ export class DurableSyncService { this.#assertSyncEpoch(expectedSyncEpoch) await this.workspaces.assertCapability(accountId, workspaceId, 'content.read') const cursor = counter(after, 'after') - const rows = await this.database.db.select().from(syncUpdates).where(and( - eq(syncUpdates.workspaceId, workspaceId), eq(syncUpdates.documentId, documentId), - gt(syncUpdates.documentSequence, cursor), - )).orderBy(asc(syncUpdates.documentSequence)).limit(limit + 1) + const [rows, documents] = await Promise.all([ + this.database.db.select().from(syncUpdates).where(and( + eq(syncUpdates.workspaceId, workspaceId), eq(syncUpdates.documentId, documentId), + gt(syncUpdates.documentSequence, cursor), + )).orderBy(asc(syncUpdates.documentSequence)).limit(limit + 1), + this.database.db.select({ + objectId: syncDocuments.objectId, + checkpointDocumentSequence: syncDocuments.checkpointDocumentSequence, + checkpointId: syncDocuments.checkpointId, + checkpointKeyVersion: syncDocuments.checkpointKeyVersion, + checkpointCiphertext: syncDocuments.checkpointCiphertext, + checkpointCiphertextHash: syncDocuments.checkpointCiphertextHash, + }).from(syncDocuments).where(and( + eq(syncDocuments.workspaceId, workspaceId), + eq(syncDocuments.documentId, documentId), + )).limit(1), + ]) const hasMore = rows.length > limit const page = rows.slice(0, limit) + const document = documents[0] + const checkpoint = document?.checkpointId + && document.checkpointKeyVersion !== null + && document.checkpointCiphertext !== null + && document.checkpointCiphertextHash !== null + ? { + objectId: document.objectId, + documentSequence: document.checkpointDocumentSequence.toString(), + checkpointId: document.checkpointId, + keyVersion: document.checkpointKeyVersion, + ciphertext: document.checkpointCiphertext, + ciphertextHash: document.checkpointCiphertextHash, + } + : null return { updates: page.map(row => ({ ...row, documentSequence: row.documentSequence.toString(), eventSequence: row.eventSequence.toString() })), + checkpoint, nextDocumentSequence: page.at(-1)?.documentSequence.toString() ?? after, hasMore, ...(this.syncEpoch === undefined ? {} : { syncEpoch: this.syncEpoch }), @@ -526,6 +608,14 @@ export class DurableSyncService { commandId: command.commandId, status: 'conflict', duplicate: false, code: 'revision_conflict', ...(actual === null ? {} : { revision: actual.toString() }), } + if (command.type === 'delete-object' && current?.deletedAt !== null && current?.deletedAt !== undefined) { + return { + commandId: command.commandId, + status: 'applied', + duplicate: true, + revision: current.currentRevision.toString(), + } + } const parentObjectId = command.parentObjectId === undefined ? current?.parentObjectId ?? null : command.parentObjectId const nameCiphertext = command.nameCiphertext === undefined @@ -886,7 +976,7 @@ export class DurableSyncService { return { commandId: command.commandId, status: 'applied', duplicate: false, sequence: finalSequence.toString() } } - if (command.type === 'append-update') { + if (command.type === 'initialize-document' || command.type === 'append-update') { await this.#assertKeyAndBlobs(tx, workspaceId, command.keyVersion, []) const [currentObject] = await tx.select({ deletedAt: objects.deletedAt, kind: objects.kind }).from(objects).where(and( eq(objects.workspaceId, workspaceId), eq(objects.objectId, command.objectId), @@ -909,6 +999,16 @@ export class DurableSyncService { if (document !== undefined && (document.objectId !== command.objectId || document.kind !== command.kind)) { throw new ApiError({ code: 'document_identity_mismatch', message: 'Document ID is already bound to another object', statusCode: 409 }) } + if (command.type === 'initialize-document' + && document !== undefined + && (document.latestDocumentSequence > 0n || document.checkpointDocumentSequence > 0n)) { + throw new ApiError({ + code: 'document_already_initialized', + message: 'Document already has an authoritative collaborative baseline', + statusCode: 409, + details: { latestDocumentSequence: document.latestDocumentSequence.toString() }, + }) + } const documentSequence = (document?.latestDocumentSequence ?? 0n) + 1n const sequence = await nextSequence(tx, workspaceId) await tx.insert(syncDocuments).values({ diff --git a/apps/server/src/durable-sync/types.ts b/apps/server/src/durable-sync/types.ts index ed4dc59..289f6e6 100644 --- a/apps/server/src/durable-sync/types.ts +++ b/apps/server/src/durable-sync/types.ts @@ -54,6 +54,14 @@ export type SyncCommand = blobRefs: string[] } & CiphertextEnvelope> } + | ({ + type: 'initialize-document' + commandId: string + updateId: string + documentId: string + objectId: string + kind: SyncObjectKind + } & CiphertextEnvelope) | ({ type: 'append-update' commandId: string diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index 63d3ffd..522dbc2 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -19,6 +19,7 @@ interface PresenceUpdateMessage { anchor: number head: number label: string + coordinateSpace: 'markdown' | 'prosemirror' | 'canvas' canvas: { nodes: Array<{ id: string; x: number; y: number }> } | null @@ -246,10 +247,19 @@ function parseRealtimeMessage(raw: RawData): RealtimeMessage { anchor: candidate.anchor, head: candidate.head, label: candidate.label.trim().slice(0, 40) || '其他设备', + coordinateSpace: parsePresenceCoordinateSpace(candidate.coordinateSpace, candidate.canvas), canvas: parseCanvasPresence(candidate.canvas), } } +function parsePresenceCoordinateSpace( + value: unknown, + canvas: unknown, +): PresenceUpdateMessage['coordinateSpace'] { + if (value === 'markdown' || value === 'prosemirror' || value === 'canvas') return value + return canvas === undefined || canvas === null ? 'prosemirror' : 'canvas' +} + function parseCanvasPresence(value: unknown): PresenceUpdateMessage['canvas'] { if (value === undefined || value === null) return null if (typeof value !== 'object' || value === null) throw new Error('Canvas presence is invalid') @@ -379,6 +389,7 @@ function sendPresence(presence: PresenceState, socket: WebSocket): void { label: presence.label, anchor: presence.anchor, head: presence.head, + coordinateSpace: presence.coordinateSpace, canvas: presence.canvas, })) } diff --git a/apps/server/src/routes/sync.ts b/apps/server/src/routes/sync.ts index a6b8b7e..e408db3 100644 --- a/apps/server/src/routes/sync.ts +++ b/apps/server/src/routes/sync.ts @@ -125,6 +125,11 @@ const Command = Type.Union([ expectedDocumentSequence: Counter, blobRefs: Type.Array(Hash, { maxItems: 1_000 }), ...Envelope, }), { minItems: 1, maxItems: 2_000 }), }), + Type.Object({ + ...CommandId, type: Type.Literal('initialize-document'), updateId: Type.String({ format: 'uuid' }), + documentId: Type.String({ minLength: 1, maxLength: 512 }), objectId: Type.String({ format: 'uuid' }), + kind: Kind, ...Envelope, + }), Type.Object({ ...CommandId, type: Type.Literal('append-update'), updateId: Type.String({ format: 'uuid' }), documentId: Type.String({ minLength: 1, maxLength: 512 }), objectId: Type.String({ format: 'uuid' }), @@ -201,7 +206,10 @@ export function createSyncRoutes( latestSequence: Counter, bootstrap: Type.Object({ required: Type.Boolean(), - reason: Type.Union([Type.Literal('cursor_ahead'), Type.Literal('cursor_expired'), Type.Null()]), + reason: Type.Union([ + Type.Literal('cursor_ahead'), Type.Literal('cursor_expired'), + Type.Literal('device_uninitialized'), Type.Literal('lag_too_large'), Type.Null(), + ]), }), limits: Type.Object({ maxCommandsPerBatch: Type.Integer(), @@ -381,6 +389,11 @@ export function createSyncRoutes( sourceDeviceId: Type.String({ format: 'uuid' }), keyVersion: Type.Integer({ minimum: 1 }), ciphertext: Type.String(), ciphertextHash: Hash, createdAt: Type.String({ format: 'date-time' }), })), + checkpoint: Type.Union([Type.Object({ + objectId: Type.String({ format: 'uuid' }), documentSequence: Counter, + checkpointId: Type.String({ format: 'uuid' }), keyVersion: Type.Integer({ minimum: 1 }), + ciphertext: Type.String(), ciphertextHash: Hash, + }), Type.Null()]), nextDocumentSequence: Counter, hasMore: Type.Boolean(), ...SyncEpochField, }) }, }, diff --git a/apps/server/src/routes/web-dashboard.ts b/apps/server/src/routes/web-dashboard.ts index 51f447c..1d81a64 100644 --- a/apps/server/src/routes/web-dashboard.ts +++ b/apps/server/src/routes/web-dashboard.ts @@ -8,18 +8,6 @@ import { requireCsrf, requireWebSession, WEB_CSRF_COOKIE, WEB_SESSION_COOKIE, } from './web-auth.js' -const Activity = Type.Object({ - sequence: CounterString, - kind: Type.String(), - changeType: Type.Union([Type.Literal('upsert'), Type.Literal('delete')]), - createdAt: Timestamp, - device: Type.Object({ - id: Type.String({ format: 'uuid' }), - name: Type.String(), - platform: Type.String(), - }), -}) - export function createWebDashboardRoutes( workspaces: WorkspaceService, webSessions: WebSessionService, @@ -54,7 +42,20 @@ export function createWebDashboardRoutes( deletedCount: Type.Integer(), updatedAt: Timestamp, })), - recentActivity: Type.Array(Activity), + activityTimeline: Type.Array(Type.Object({ + startedAt: Timestamp, + updates: Type.Integer(), + deletes: Type.Integer(), + kinds: Type.Array(Type.Object({ + kind: Type.String(), + updates: Type.Integer(), + deletes: Type.Integer(), + })), + })), + activityKinds: Type.Array(Type.Object({ + kind: Type.String(), + count: Type.Integer(), + })), }), }, }, @@ -69,7 +70,9 @@ export function createWebDashboardRoutes( 200: Type.Array(Type.Object({ id: Type.String({ format: 'uuid' }), nameCiphertext: Type.String(), + type: Type.Union([Type.Literal('account-data'), Type.Literal('library')]), isDefault: Type.Boolean(), + isNoteGenDefault: Type.Boolean(), latestSequence: CounterString, latestKeyVersion: Type.Integer(), encryptionMode: Type.Union([Type.Literal('managed'), Type.Literal('e2ee')]), diff --git a/apps/server/src/workspaces/service.ts b/apps/server/src/workspaces/service.ts index c5bbca8..2ea2376 100644 --- a/apps/server/src/workspaces/service.ts +++ b/apps/server/src/workspaces/service.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto' -import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' import type { DatabaseContext } from '../database/client.js' import { devices, objects, workspaceKeyEnvelopes, workspaceKeys, workspaceMembers, workspaces } from '../database/schema.js' import { ApiError } from '../errors.js' @@ -8,6 +8,8 @@ import type { ChangeNotifier } from '../sync/types.js' import type { UsageService } from '../usage/service.js' import { workspaceCapabilities, type WorkspaceCapability } from './capabilities.js' +const NOTEGEN_DEFAULT_LIBRARY_IDEMPOTENCY_KEY = 'notegen.default-library.v1' + export interface CreateWorkspaceInput { nameCiphertext: string keyVersion: number @@ -56,11 +58,40 @@ export class WorkspaceService { eq(workspaces.accountId, accountId), eq(workspaces.creationIdempotencyKey, input.idempotencyKey), )).limit(1) if (existing !== undefined) { - if (existing.creationRequestHash !== requestHash) { + // The default NoteGen library is bootstrapped independently by every + // device. Its encrypted name and generated managed key are expected + // to differ, so the account-scoped key elects the first successful + // creation instead of treating later devices as conflicting retries. + if (existing.creationRequestHash !== requestHash + && input.idempotencyKey !== NOTEGEN_DEFAULT_LIBRARY_IDEMPOTENCY_KEY) { throw new ApiError({ code: 'idempotency_conflict', message: 'Workspace creation key was reused with different input', statusCode: 409 }) } return { ...existing, latestSequence: existing.latestSequence.toString(), created: false } } + if (input.idempotencyKey === NOTEGEN_DEFAULT_LIBRARY_IDEMPOTENCY_KEY) { + const [legacyDefault] = await tx.select({ + id: workspaces.id, + createdAt: workspaces.createdAt, + latestSequence: workspaces.latestSequence, + nameCiphertext: workspaces.nameCiphertext, + }).from(workspaces).where(and( + eq(workspaces.accountId, accountId), + eq(workspaces.type, 'library'), + isNull(workspaces.deletedAt), + )).orderBy(asc(workspaces.createdAt)).limit(1).for('update') + if (legacyDefault !== undefined) { + await tx.update(workspaces).set({ + creationIdempotencyKey: input.idempotencyKey, + creationRequestHash: requestHash, + updatedAt: new Date(), + }).where(eq(workspaces.id, legacyDefault.id)) + return { + ...legacyDefault, + latestSequence: legacyDefault.latestSequence.toString(), + created: false, + } + } + } } await this.usage?.admitWorkspace(tx, accountId, workspaceLimit) const [workspace] = await tx.insert(workspaces).values({ @@ -258,7 +289,7 @@ export class WorkspaceService { } async getAccountSyncOverview(accountId: string) { - const [summaries, kinds, recentActivity, usage] = await Promise.all([ + const [summaries, kinds, activityTimeline, activityKinds, usage] = await Promise.all([ this.database.sql }>>` - select e.sequence::text as sequence, v.kind::text as kind, - case when e.event_type = 'object.deleted' then 'delete' else 'upsert' end as change_type, - e.created_at, d.id as device_id, - d.name as device_name, d.platform as device_platform + with buckets as ( + select generate_series( + date_trunc('minute', now()) - interval '59 minutes', + date_trunc('minute', now()), + interval '1 minute' + ) as bucket_start + ), activity_by_kind as ( + select date_trunc('minute', e.created_at) as bucket_start, + v.kind::text as kind, + count(distinct e.object_id) filter (where e.event_type = 'object.upserted')::int as updates, + count(distinct e.object_id) filter (where e.event_type = 'object.deleted')::int as deletes + from sync_events e + join workspaces w on w.id = e.workspace_id + join object_versions v on v.workspace_id = e.workspace_id + and v.object_id = e.object_id and v.sequence = e.sequence + where w.account_id = ${accountId} and w.deleted_at is null + and e.event_type in ('object.upserted', 'object.deleted') + and e.created_at >= date_trunc('minute', now()) - interval '59 minutes' + and e.created_at < date_trunc('minute', now()) + interval '1 minute' + group by date_trunc('minute', e.created_at), v.kind + ) + select buckets.bucket_start, + coalesce(sum(activity_by_kind.updates), 0)::int as updates, + coalesce(sum(activity_by_kind.deletes), 0)::int as deletes, + coalesce( + jsonb_agg( + jsonb_build_object( + 'kind', activity_by_kind.kind, + 'updates', activity_by_kind.updates, + 'deletes', activity_by_kind.deletes + ) order by activity_by_kind.kind + ) filter (where activity_by_kind.kind is not null), + '[]'::jsonb + ) as kinds + from buckets + left join activity_by_kind on activity_by_kind.bucket_start = buckets.bucket_start + group by buckets.bucket_start + order by buckets.bucket_start + `, + this.database.sql>` + select v.kind::text as kind, count(distinct e.object_id)::int as count from sync_events e join workspaces w on w.id = e.workspace_id join object_versions v on v.workspace_id = e.workspace_id and v.object_id = e.object_id and v.sequence = e.sequence - join devices d on d.id = e.source_device_id where w.account_id = ${accountId} and w.deleted_at is null and e.event_type in ('object.upserted', 'object.deleted') - order by e.created_at desc, e.sequence desc limit 30 + and e.created_at >= date_trunc('minute', now()) - interval '59 minutes' + group by v.kind + order by count desc, v.kind `, this.usage?.getSnapshot(accountId) ?? null, ]) @@ -361,16 +431,15 @@ export class WorkspaceService { deletedCount: item.deleted_count, updatedAt: item.updated_at, })), - recentActivity: recentActivity.map((item) => ({ - sequence: item.sequence, + activityTimeline: activityTimeline.map((item) => ({ + startedAt: item.bucket_start, + updates: item.updates, + deletes: item.deletes, + kinds: item.kinds, + })), + activityKinds: activityKinds.map((item) => ({ kind: item.kind, - changeType: item.change_type, - createdAt: item.created_at, - device: { - id: item.device_id, - name: item.device_name, - platform: item.device_platform, - }, + count: item.count, })), } } @@ -379,7 +448,9 @@ export class WorkspaceService { const rows = await this.database.db.select({ id: workspaces.id, nameCiphertext: workspaces.nameCiphertext, + type: workspaces.type, isDefault: workspaces.isDefault, + creationIdempotencyKey: workspaces.creationIdempotencyKey, latestSequence: workspaces.latestSequence, createdAt: workspaces.createdAt, updatedAt: workspaces.updatedAt, @@ -415,8 +486,9 @@ export class WorkspaceService { const objectCountsByWorkspace = new Map(objectCounts.map((item) => ( [item.workspaceId, item] as const ))) - return rows.map((row) => ({ + return rows.map(({ creationIdempotencyKey, ...row }) => ({ ...row, + isNoteGenDefault: creationIdempotencyKey === NOTEGEN_DEFAULT_LIBRARY_IDEMPOTENCY_KEY, latestSequence: row.latestSequence.toString(), latestKeyVersion: latestKeyVersionByWorkspace.get(row.id) ?? 0, encryptionMode: managedWorkspaceIds.has(row.id) ? 'managed' as const : 'e2ee' as const, @@ -721,6 +793,7 @@ export class WorkspaceService { const [current] = await tx.select({ id: workspaces.id, deletedAt: workspaces.deletedAt, type: workspaces.type, ownerAccountId: workspaces.accountId, + creationIdempotencyKey: workspaces.creationIdempotencyKey, }).from(workspaces) .where(and(eq(workspaces.id, workspaceId), ...(options.allowDefault === false ? [eq(workspaces.isDefault, false)] : []))) @@ -733,6 +806,14 @@ export class WorkspaceService { statusCode: 409, }) } + if (current.creationIdempotencyKey === NOTEGEN_DEFAULT_LIBRARY_IDEMPOTENCY_KEY + && options.allowDefault !== true) { + throw new ApiError({ + code: 'workspace_default_delete_forbidden', + message: 'The default NoteGen workspace cannot be deleted', + statusCode: 409, + }) + } if (current.deletedAt === null) { await tx.update(workspaces).set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(workspaces.id, workspaceId)) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2f63547..795f38e 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -71,6 +71,16 @@ --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); + --activity-note: oklch(0.62 0.18 255); + --activity-folder: oklch(0.72 0.16 75); + --activity-asset: oklch(0.68 0.14 205); + --activity-canvas: oklch(0.64 0.18 305); + --activity-record: oklch(0.7 0.17 45); + --activity-tag: oklch(0.66 0.16 145); + --activity-conversation: oklch(0.63 0.2 25); + --activity-memory: oklch(0.62 0.16 335); + --activity-setting: oklch(0.58 0.04 255); + --activity-collaboration: oklch(0.65 0.14 180); --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -106,6 +116,16 @@ --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); + --activity-note: oklch(0.72 0.16 255); + --activity-folder: oklch(0.8 0.14 80); + --activity-asset: oklch(0.76 0.13 205); + --activity-canvas: oklch(0.74 0.16 305); + --activity-record: oklch(0.78 0.15 50); + --activity-tag: oklch(0.75 0.14 145); + --activity-conversation: oklch(0.72 0.18 25); + --activity-memory: oklch(0.72 0.15 335); + --activity-setting: oklch(0.72 0.04 255); + --activity-collaboration: oklch(0.75 0.12 180); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); diff --git a/apps/web/components/account-portal.tsx b/apps/web/components/account-portal.tsx index c73a372..42e624c 100644 --- a/apps/web/components/account-portal.tsx +++ b/apps/web/components/account-portal.tsx @@ -15,6 +15,7 @@ import { UsersIcon, } from "lucide-react" import QRCode from "react-qr-code" +import { Bar, BarChart, CartesianGrid, XAxis } from "recharts" import { AdminShell, type AdminSection } from "@/components/admin-shell" import { NOTEGEN_SITE_URL } from "@/components/notegen-brand" @@ -26,6 +27,7 @@ import { } from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" import { Card, CardContent, @@ -33,6 +35,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart" import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Input } from "@/components/ui/input" @@ -88,6 +98,19 @@ type AuthMode = "login" | "register" type AuthStep = "credentials" | "totp" | "forgot" | "reset" | "verify" | "verification-pending" type AdminDataView = "overview" | "accounts" | "data" | "audit" | "tools" +const syncActivityKindDefinitions: ChartConfig = { + note: { label: "写作", color: "var(--activity-note)" }, + folder: { label: "目录", color: "var(--activity-folder)" }, + asset: { label: "附件", color: "var(--activity-asset)" }, + canvas: { label: "画布", color: "var(--activity-canvas)" }, + record: { label: "记录", color: "var(--activity-record)" }, + tag: { label: "标签", color: "var(--activity-tag)" }, + conversation: { label: "对话", color: "var(--activity-conversation)" }, + memory: { label: "记忆", color: "var(--activity-memory)" }, + setting: { label: "设置", color: "var(--activity-setting)" }, + collaboration: { label: "实时文档", color: "var(--activity-collaboration)" }, +} satisfies ChartConfig + export function AccountPortal({ forceAuthenticationFlow = false }: { forceAuthenticationFlow?: boolean }) { const [, setRelativeTimeTick] = useState(0) const [account, setAccount] = useState(null) @@ -244,6 +267,14 @@ export function AccountPortal({ forceAuthenticationFlow = false }: { forceAuthen return () => document.removeEventListener("visibilitychange", onVisibility) }, [account, loadDashboardData]) + useEffect(() => { + if (!account || !["overview", "devices", "workspaces"].includes(section)) return + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") void loadDashboardData() + }, 15_000) + return () => window.clearInterval(timer) + }, [account, loadDashboardData, section]) + useEffect(() => { if (!account) return const timer = window.setInterval(() => { @@ -605,6 +636,10 @@ function OverviewSection({ overview, devices, onNavigate }: { onNavigate: (section: AdminSection) => void }) { const contentStats = summarizeContentKinds(overview?.kinds ?? []) + const activityChart = summarizeSyncActivity( + overview?.activityTimeline ?? [], + overview?.activityKinds ?? [], + ) const storage = overview?.storageUsage const activeBytes = storage ? sumByteStrings( storage.activeObjectBytes, storage.activeCrdtBytes, storage.activeBlobBytes, @@ -703,22 +738,61 @@ function OverviewSection({ overview, devices, onNavigate }: { 最近同步活动 - 显示设备、内容类型和操作,不展示文件名或正文。 + 按分钟汇总最近 1 小时内发生变化的对象,不展示文件名或正文。 - {overview?.recentActivity.length ? ( - - {overview.recentActivity.slice(0, 10).map((activity) => ( - - - - {activity.changeType === "delete" ? "删除" : "更新"} {kindLabel(activity.kind)} - {activity.device.name} · {formatDate(activity.createdAt)} - - #{activity.sequence} - - ))} - + {activityChart.total ? ( +
+
+ + + + +
+ +
+
+
+
活动趋势
+
每根细柱表示一分钟内发生变化的唯一对象,不同颜色代表不同内容类型
+
+
+ + + + + } /> + } /> + {activityChart.series.map((item) => ( + + ))} + + +
+ {activityChart.kinds.map((item) => ( + + + ))} +
+
+
) : onNavigate("connect")} />}
@@ -747,16 +821,19 @@ function WorkspaceManagement({ }) { const sorted = [...workspaces].sort((left, right) => ( Number(right.isDefault) - Number(left.isDefault) + || Number(right.isNoteGenDefault) - Number(left.isNoteGenDefault) || right.objectCount - left.objectCount || new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime() )) - const historicalCount = workspaces.filter((workspace) => !workspace.isDefault).length + const historicalCount = workspaces.filter((workspace) => ( + !workspace.isDefault && !workspace.isNoteGenDefault + )).length return ( 工作区列表 - 查看工作区状态与用量;默认工作区受到保护,历史工作区可在确认后软删除。 + 查看个人数据空间、默认写作工作区和历史工作区;只有真正的历史工作区可以软删除。 {recentlyDeletedWorkspace ? 历史工作区已删除工作区 {recentlyDeletedWorkspace.id.slice(0, 8)} 已进入软删除状态,可以立即恢复。 : null} @@ -769,21 +846,30 @@ function WorkspaceManagement({ ) : null} {sorted.length ? ( - {sorted.map((workspace) => ( - - {workspace.isDefault ? : } + {sorted.map((workspace) => { + const protectedWorkspace = workspace.isDefault || workspace.isNoteGenDefault + const title = workspace.isDefault + ? "个人数据空间" + : workspace.isNoteGenDefault + ? `默认写作工作区 ${workspace.id.slice(0, 8)}` + : `历史工作区 ${workspace.id.slice(0, 8)}` + const kindLabel = workspace.isDefault + ? "个人数据" + : workspace.isNoteGenDefault ? "默认写作" : "历史" + return + {protectedWorkspace ? : } - {workspace.isDefault ? "当前默认工作区" : `历史工作区 ${workspace.id.slice(0, 8)}`} + {title} {workspace.objectCount} 项内容 · {workspace.deletedObjectCount} 项删除记录 · 创建于 {formatDate(workspace.createdAt)} - - {workspace.isDefault ? "默认" : "历史"} + + {kindLabel} {workspace.encryptionMode === "managed" ? "托管加密" : "E2EE"} - {!workspace.isDefault ? : null} - ))} + })} ) : } @@ -812,56 +898,86 @@ function DeviceManagement({ onRevoke: (id: string) => void onConnect: () => void }) { + const activeDevices = sortDevices(devices.filter((device) => !device.revokedAt)) + const revokedDevices = sortDevices(devices.filter((device) => Boolean(device.revokedAt))) + return ( 已关联设备 - 每台设备拥有独立会话,可以单独撤销;撤销不会删除已同步内容。 + 当前共 {activeDevices.length} 台有效设备。历史授权是已经失效的登录身份,不代表新增了物理设备;撤销不会删除已同步内容。 - - {devices.length ? ( + + {activeDevices.length ? ( - {sortDevices(devices).map((device) => ( - - - - {device.name} - - {device.platform} · 最近活动 {formatDate(device.lastSeenAt)} · 关联于 {formatDate(device.createdAt)} - -
- - {device.acknowledgedAt ? ( - 最后确认 {formatDate(device.acknowledgedAt)} - ) : null} -
- {isLikelyDuplicateDevice(device, devices) ? 疑似重复设备 : null} -
- - {device.revokedAt ? ( - 已撤销 - ) : ( - onRevoke(device.id)} - /> - )} - -
+ {activeDevices.map((device) => ( + ))}
) : } + + {revokedDevices.length ? ( + + + + 历史授权({revokedDevices.length}) + + + + {revokedDevices.map((device) => ( + + ))} + + + + ) : null}
) } +function DeviceManagementItem({ device, devices, busy, onRevoke }: { + device: Device + devices: Device[] + busy: boolean + onRevoke: (id: string) => void +}) { + return ( + + + + {device.name} + + {device.platform} · 最近活动 {formatDate(device.lastSeenAt)} · 关联于 {formatDate(device.createdAt)} + +
+ + {device.acknowledgedAt ? ( + 最后确认 {formatDate(device.acknowledgedAt)} + ) : null} +
+ {isLikelyDuplicateDevice(device, devices) ? 疑似重复设备 : null} +
+ + {device.revokedAt ? ( + 已撤销 + ) : ( + onRevoke(device.id)} + /> + )} + +
+ ) +} + function DeviceSyncBadge({ device }: { device: Device }) { if (device.syncStatus === "caught-up") return 已追平 - if (device.syncStatus === "behind") return 落后 {device.pendingEventCount} 项 + if (device.syncStatus === "behind") return {device.pendingEventCount} 个对象有变更 return 从未确认同步 } @@ -1959,22 +2075,62 @@ function isLikelyDuplicateDevice(device: Device, devices: Device[]): boolean { return newest.id !== device.id } -function kindLabel(kind: string): string { - const labels: Record = { - note: "Markdown 笔记", - folder: "目录", - asset: "附件", - canvas: "画布", - record: "记录", - tag: "标签", - mark: "记录", - conversation: "对话", - memory: "记忆", - setting: "设置", - "yjs-checkpoint": "实时文档快照", - "yjs-update": "实时文档增量", +function summarizeSyncActivity( + timeline: SyncOverview["activityTimeline"], + kinds: SyncOverview["activityKinds"], +) { + const seriesKeys = [...new Set([ + ...kinds.map((item) => activityKindKey(item.kind)), + ...timeline.flatMap((bucket) => bucket.kinds.map((item) => activityKindKey(item.kind))), + ])] + const config = Object.fromEntries(seriesKeys.map((key) => [ + key, + syncActivityKindDefinitions[key] ?? { label: key, color: "var(--chart-5)" }, + ])) satisfies ChartConfig + const chartBuckets = timeline.map((bucket) => { + const values = Object.fromEntries(seriesKeys.map((key) => [key, 0])) + for (const item of bucket.kinds) { + const key = activityKindKey(item.kind) + values[key] = (values[key] ?? 0) + item.updates + item.deletes + } + return { + key: bucket.startedAt, + label: new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(bucket.startedAt)), + ...values, + } + }) + const updates = timeline.reduce((total, bucket) => total + bucket.updates, 0) + const deletes = timeline.reduce((total, bucket) => total + bucket.deletes, 0) + const kindCounts = new Map() + for (const item of kinds) { + const key = activityKindKey(item.kind) + kindCounts.set(key, (kindCounts.get(key) ?? 0) + item.count) } - return labels[kind] ?? kind + return { + buckets: chartBuckets, + total: updates + deletes, + updates, + deletes, + config, + series: seriesKeys.map((key) => ({ key })), + kinds: seriesKeys.map((key) => ({ + key, + label: String(config[key]?.label ?? key), + color: config[key]?.color ?? "var(--chart-5)", + count: kindCounts.get(key) ?? 0, + })), + } +} + +function activityKindKey(kind: string): string { + if (kind === "mark" || kind === "record") return "record" + if (kind === "message" || kind === "conversation") return "conversation" + if (kind === "yjs-checkpoint" || kind === "yjs-update") return "collaboration" + return kind } function summarizeContentKinds(kinds: SyncOverview["kinds"]): Array<{ @@ -1983,9 +2139,9 @@ function summarizeContentKinds(kinds: SyncOverview["kinds"]): Array<{ deletedCount: number }> { const groups = [ - { label: "笔记", kinds: ["note"] }, + { label: "写作", kinds: ["note"] }, { label: "记录", kinds: ["mark", "record"] }, - { label: "绘图", kinds: ["canvas"] }, + { label: "画布", kinds: ["canvas"] }, { label: "对话", kinds: ["conversation"] }, { label: "记忆", kinds: ["memory"] }, { label: "配置", kinds: ["setting"] }, diff --git a/apps/web/components/ui/chart.tsx b/apps/web/components/ui/chart.tsx new file mode 100644 index 0000000..7c2dc84 --- /dev/null +++ b/apps/web/components/ui/chart.tsx @@ -0,0 +1,373 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + string, + { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +> + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +function ChartContainer({ + id, + className, + children, + config, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + const uniqueId = React.useId() + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme ?? config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +