diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 38e97d0c..91f49284 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -252,9 +252,24 @@ but its runtime adapter and all Session/Turn delivery remain future work. The four `OPENTAG_BOOTSTRAP_*` values are inputs to this one-time command only; the running server does not read them. The bootstrap email is Account profile data, not an email/password credential. The Account login-code flow resolves a -stable user ID and then uses the provider-neutral token issuer. Future Google or OIDC identity resolvers can join at that -boundary without changing JWT claims. Internal grants are still loaded from PostgreSQL as a Phase 2 compatibility seam; -they are not exposed as Admin membership. +stable user ID and then uses the provider-neutral token issuer. Internal grants are still loaded from PostgreSQL as a +Phase 2 compatibility seam; they are not exposed as Admin membership. + +That issuer now hands out a Better Auth session rather than a signed access/refresh pair, so a CLI credential is a row +the server can withdraw instead of a signature it can only wait out. The exchange response keeps its four fields and +`accessToken` and `refreshToken` carry the same session token, which is why a CLI built before the cutover keeps working +unchanged. `OPENTAG_SESSION_TTL_SECONDS` is that credential's whole lifetime, defaulted to what the refresh token's was +because it replaces the same thing: how long a client may be idle and still be signed in. Refreshing rotates — the +replacement is issued, then the presented token is withdrawn — so a copy taken before the last refresh stops working +rather than running to its own expiry. + +One consequence is worth stating plainly: a disclosed credential is now usable for the session lifetime rather than the +old fifteen-minute access window. What made that window necessary was that its thirty-day refresh partner could not be +revoked at all; a session can be, immediately, which is the trade this makes. + +Credentials issued before the cutover still verify, and `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` and +`OPENTAG_REFRESH_TOKEN_TTL_SECONDS` govern only those. A browser holding one moves onto a session the next time it +refreshes; nothing is issued against them again. An Account email is stored lowercased, and one address identifies at most one Account. The identity resolver enforces that by serializing on the address before deciding whether to create or attach, so it holds without a database @@ -287,10 +302,11 @@ export OPENTAG_DEV_AUTH_EMAIL=admin@example.com ``` Both `OPENTAG_HOST` and `OPENTAG_PUBLIC_URL` must remain loopback addresses. The login page then shows -`Dev: bypass Google`. The callback resolves exactly one existing user by case-insensitive email and issues the normal -browser session; it never creates an Account or internal compatibility records and still rejects suspended Accounts or -Accounts without the required internal grant. Missing or duplicate email matches fail closed. The server refuses this configuration in `staging` and -`prod`. +`Dev: bypass Google`. The callback resolves exactly one existing user by case-insensitive email and then issues the +normal browser session through Better Auth, so it is the same revocable session a Google sign-in produces and signing +out ends it. Which Account it signs in is fixed from configuration, not taken from the request. It never creates an +Account or internal compatibility records and still rejects suspended Accounts; a missing or duplicate email match +fails closed. The server refuses this configuration in `staging` and `prod`. `OPENTAG_ENV` is the only OpenTag environment and release-channel selector. `dev` selects local development behavior and the `opentag-dev` binary, `staging` selects `open-tag-staging` / `opentag-staging`, and `prod` selects @@ -379,8 +395,9 @@ processes. | `OPENTAG_OTEL_HEADERS` | empty | Secret OTLP headers in comma-separated `key=value` form | | `OPENTAG_OTEL_ENVIRONMENT` | `OPENTAG_ENV` | Trace deployment environment label | | `OPENTAG_OTEL_SAMPLE_RATE` | `1` | Global trace head sample rate from `0` to `1` | -| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | Access-token lifetime | -| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | Refresh-JWT lifetime | +| `OPENTAG_SESSION_TTL_SECONDS` | `2592000` | Account session lifetime, browser and CLI alike | +| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | Access-JWT lifetime; only credentials issued before the Better Auth cutover | +| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | Refresh-JWT lifetime; only credentials issued before the Better Auth cutover | | `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | empty | Staging-only Account UUID allowed to reset the [Onboarding Lab](./docs/staging-onboarding-lab.md) Account; Scenario Preview needs no configuration | | `OPENTAG_HOME` | channel-specific | Root for lifecycle-separated `config/`, `data/`, `state/`, and `logs/` (`~/.opentag-dev` in source) | diff --git a/DEVELOPMENT.zh-CN.md b/DEVELOPMENT.zh-CN.md index 3693d4d4..ad11b619 100644 --- a/DEVELOPMENT.zh-CN.md +++ b/DEVELOPMENT.zh-CN.md @@ -244,8 +244,19 @@ delivery 仍属于后续工作。 这四个 `OPENTAG_BOOTSTRAP_*` 值仅作为一次性命令的输入,运行中的 Server 不会读取它们。 bootstrap email 是 Account 资料,不是邮箱密码凭据。Account 登录 code 流程先解析稳定的 user ID,再进入与 provider -无关的 token 颁发边界。未来 Google 或 OIDC identity resolver 可以接入这个边界,无需改变 JWT claims。内部 grant -仍会从 PostgreSQL 读取,作为 Phase 2 前的兼容 seam;产品不把它暴露为 Admin 成员关系。 +无关的 token 颁发边界。内部 grant 仍会从 PostgreSQL 读取,作为 Phase 2 前的兼容 seam;产品不把它暴露为 Admin 成员关系。 + +该边界现在签发的是 Better Auth session,而不是签名的 access/refresh 对:CLI 凭据成为服务端可以撤销的一行记录, +而不再是只能等它过期的一段签名。兑换响应仍是原来的四个字段,`accessToken` 与 `refreshToken` 携带同一个 session +token,因此切换前构建的 CLI 无需升级即可继续工作。`OPENTAG_SESSION_TTL_SECONDS` 就是这个凭据的完整有效期, +默认值取自原 refresh token 的有效期,因为它替代的正是同一件事:客户端可以闲置多久仍保持登录。refresh 采用轮换—— +先签发替代凭据,再撤销所呈现的那个——因此上次 refresh 之前被复制走的副本会立即失效,而不是继续有效到自身过期。 + +有一处代价需要明说:凭据一旦泄露,可用时长从原先 15 分钟的 access 窗口变成整个 session 有效期。而当初之所以需要 +这个短窗口,正是因为与之配对的 30 天 refresh token 根本无法吊销;session 则可以随时吊销,这就是这次取舍。 + +切换前签发的凭据仍可通过校验,`OPENTAG_ACCESS_TOKEN_TTL_SECONDS` 与 `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` 只对它们 +生效。持有此类凭据的浏览器会在下一次 refresh 时换成 session;系统不会再基于它们签发任何新凭据。 Account email 以小写存储,且一个地址最多对应一个 Account。这由 identity resolver 保证:它在决定新建还是挂载之前先对该地址 串行化,因此不依赖数据库约束也成立;`users_email_unique` 索引作为兜底,用于防范绕过 resolver 的写入方,并且只在没有任何 @@ -275,9 +286,10 @@ export OPENTAG_DEV_AUTH_EMAIL=admin@example.com ``` `OPENTAG_HOST` 与 `OPENTAG_PUBLIC_URL` 都必须保持为 loopback 地址。登录页随后会显示 -`Dev: bypass Google`。callback 会按不区分大小写的 email 精确解析唯一一个已有用户并签发正常浏览器 session; -它不会创建 Account 或内部兼容记录,且仍会拒绝 suspended Account 或缺少所需内部 grant 的 Account。email 不存在或有重复匹配时 -会 fail closed。Server 会在 `staging` 和 `prod` 环境拒绝这组配置。 +`Dev: bypass Google`。callback 会按不区分大小写的 email 精确解析唯一一个已有用户,再通过 Better Auth 签发正常浏览器 +session,因此它与 Google 登录产生的是同一种可吊销 session,登出即可结束它。签入哪个 Account 由配置固定,不取自请求。 +它不会创建 Account 或内部兼容记录,且仍会拒绝 suspended Account;email 不存在或有重复匹配时会 fail closed。 +Server 会在 `staging` 和 `prod` 环境拒绝这组配置。 `OPENTAG_ENV` 是 OpenTag 唯一的环境与发布 channel 选择器。`dev` 对应本地开发行为与 `opentag-dev` binary, `staging` 对应 `open-tag-staging` / `opentag-staging`,`prod` 对应 `open-tag` / `opentag`。托管 Node.js 进程的 @@ -359,8 +371,9 @@ setup attempt 并记录结果,然后把一条已授权的 binding 写入数据 | `OPENTAG_OTEL_HEADERS` | 空 | 逗号分隔 `key=value` 格式的 secret OTLP headers | | `OPENTAG_OTEL_ENVIRONMENT` | `OPENTAG_ENV` | Trace deployment environment 标签 | | `OPENTAG_OTEL_SAMPLE_RATE` | `1` | `0` 到 `1` 的全局 trace head sample rate | -| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | access token 有效期 | -| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | refresh JWT 有效期 | +| `OPENTAG_SESSION_TTL_SECONDS` | `2592000` | Account session 有效期,浏览器与 CLI 相同 | +| `OPENTAG_ACCESS_TOKEN_TTL_SECONDS` | `900` | access JWT 有效期;仅适用于 Better Auth 切换前签发的凭据 | +| `OPENTAG_REFRESH_TOKEN_TTL_SECONDS` | `2592000` | refresh JWT 有效期;仅适用于 Better Auth 切换前签发的凭据 | | `OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID` | 空 | 仅限 staging,允许 reset [Onboarding Lab](./docs/zh-CN/staging-onboarding-lab.md) Account 的 Account UUID;Scenario Preview 不需要配置 | | `OPENTAG_HOME` | 随 channel 而定 | 按生命周期分层的 `config/`、`data/`、`state/`、`logs/` 根目录(源码默认为 `~/.opentag-dev`) | diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index db40f975..999a0b2b 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -86,6 +86,8 @@ export class ApiError extends Error { } export class BrowserApi { + private refreshInFlight?: Promise; + constructor(readonly fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)) {} me(): Promise { @@ -316,11 +318,7 @@ export class BrowserApi { private async fetchWithRefresh(path: string, init: RequestInit = {}, retry = true): Promise { const response = await this.fetchImpl(path, { ...init, credentials: "same-origin" }); if (response.status !== 401 || !retry || !this.csrfToken()) return response; - const refreshed = await this.fetchImpl("/api/v1/auth/browser/refresh", { - method: "POST", - credentials: "same-origin", - headers: this.csrfHeaders(), - }); + const refreshed = await this.refreshOnce(); if (!refreshed.ok) return response; const headers = new Headers(init.headers); const csrf = this.csrfToken(); @@ -330,6 +328,24 @@ export class BrowserApi { return this.fetchWithRefresh(path, { ...init, headers }, false); } + /** + * Collapses concurrent refreshes into one. + * + * Several requests can meet a `401` at once — the page loads more than one resource — and each would otherwise send + * the same cookie to an endpoint that exchanges it. The server converges those on one session regardless; this keeps + * the browser from asking it to. + */ + private refreshOnce(): Promise { + this.refreshInFlight ??= this.fetchImpl("/api/v1/auth/browser/refresh", { + method: "POST", + credentials: "same-origin", + headers: this.csrfHeaders(), + }).finally(() => { + this.refreshInFlight = undefined; + }); + return this.refreshInFlight; + } + private apiError(response: Response, body: unknown): ApiError { const parsed = ErrorEnvelopeSchema.safeParse(body); if (!parsed.success) return new ApiError(response.status, "Request failed"); diff --git a/packages/server/drizzle/0023_motionless_gideon.sql b/packages/server/drizzle/0023_motionless_gideon.sql new file mode 100644 index 00000000..df936499 --- /dev/null +++ b/packages/server/drizzle/0023_motionless_gideon.sql @@ -0,0 +1,7 @@ +CREATE TABLE "account_legacy_upgrades" ( + "token_hash" text PRIMARY KEY NOT NULL, + "session_token" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/packages/server/drizzle/meta/0023_snapshot.json b/packages/server/drizzle/meta/0023_snapshot.json new file mode 100644 index 00000000..443c7541 --- /dev/null +++ b/packages/server/drizzle/meta/0023_snapshot.json @@ -0,0 +1,4108 @@ +{ + "id": "7b7d6be6-6796-45e8-9a47-7bba50b8469d", + "prevId": "2914c5d6-179d-4098-aa3d-55c4594cf17a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_runtime_configs": { + "name": "agent_runtime_configs", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "nextval('runtime_config_revision_sequence')" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_duration_ms": { + "name": "max_duration_ms", + "type": "integer", + "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": { + "agent_runtime_configs_agent_id_agents_id_fk": { + "name": "agent_runtime_configs_agent_id_agents_id_fk", + "tableFrom": "agent_runtime_configs", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_runtime_configs_revision_safe_positive": { + "name": "agent_runtime_configs_revision_safe_positive", + "value": "\"agent_runtime_configs\".\"revision\" > 0 and \"agent_runtime_configs\".\"revision\" <= 9007199254740991" + }, + "agent_runtime_configs_max_duration_valid": { + "name": "agent_runtime_configs_max_duration_valid", + "value": "\"agent_runtime_configs\".\"max_duration_ms\" is null or (\"agent_runtime_configs\".\"max_duration_ms\" > 0 and \"agent_runtime_configs\".\"max_duration_ms\" <= 86400000)" + } + }, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "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 + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "creation_intent_id": { + "name": "creation_intent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creation_intent_fingerprint": { + "name": "creation_intent_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_computer_id": { + "name": "workspace_computer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_provider": { + "name": "runtime_provider", + "type": "agent_runtime_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "receive_mode": { + "name": "receive_mode", + "type": "agent_receive_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all_message'" + }, + "status": { + "name": "status", + "type": "agent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_workspace_name_active_unique": { + "name": "agents_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_creation_intent_unique": { + "name": "agents_creation_intent_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creation_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agents\".\"creation_intent_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_workspace_id_idx": { + "name": "agents_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_created_by_user_id_idx": { + "name": "agents_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_workspace_computer_id_idx": { + "name": "agents_workspace_computer_id_idx", + "columns": [ + { + "expression": "workspace_computer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_workspace_id_workspaces_id_fk": { + "name": "agents_workspace_id_workspaces_id_fk", + "tableFrom": "agents", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "agents_created_by_user_id_users_id_fk": { + "name": "agents_created_by_user_id_users_id_fk", + "tableFrom": "agents", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "agents_workspace_enrollment_fk": { + "name": "agents_workspace_enrollment_fk", + "tableFrom": "agents", + "tableTo": "workspace_computers", + "columnsFrom": [ + "workspace_id", + "workspace_computer_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agents_creation_intent_pair": { + "name": "agents_creation_intent_pair", + "value": "(\"agents\".\"creation_intent_id\" is null) = (\"agents\".\"creation_intent_fingerprint\" is null)" + }, + "agents_revision_positive": { + "name": "agents_revision_positive", + "value": "\"agents\".\"revision\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.account_cli_login_codes": { + "name": "account_cli_login_codes", + "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 + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issued_by_user_id": { + "name": "issued_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_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 + } + }, + "indexes": { + "account_cli_login_codes_user_created_idx": { + "name": "account_cli_login_codes_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_cli_login_codes_user_id_users_id_fk": { + "name": "account_cli_login_codes_user_id_users_id_fk", + "tableFrom": "account_cli_login_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "account_cli_login_codes_issued_by_user_id_users_id_fk": { + "name": "account_cli_login_codes_issued_by_user_id_users_id_fk", + "tableFrom": "account_cli_login_codes", + "tableTo": "users", + "columnsFrom": [ + "issued_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_cli_login_codes_token_hash_unique": { + "name": "account_cli_login_codes_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "account_cli_login_codes_expiry": { + "name": "account_cli_login_codes_expiry", + "value": "\"account_cli_login_codes\".\"expires_at\" > \"account_cli_login_codes\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_admin_grants": { + "name": "workspace_admin_grants", + "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 + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_admin_grants_active_workspace_user_unique": { + "name": "workspace_admin_grants_active_workspace_user_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_admin_grants\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_admin_grants_active_user_workspace_idx": { + "name": "workspace_admin_grants_active_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_admin_grants\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_admin_grants_workspace_granted_idx": { + "name": "workspace_admin_grants_workspace_granted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "granted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_admin_grants_workspace_id_workspaces_id_fk": { + "name": "workspace_admin_grants_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_admin_grants", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_admin_grants_user_id_users_id_fk": { + "name": "workspace_admin_grants_user_id_users_id_fk", + "tableFrom": "workspace_admin_grants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_admin_grants_granted_by_user_id_users_id_fk": { + "name": "workspace_admin_grants_granted_by_user_id_users_id_fk", + "tableFrom": "workspace_admin_grants", + "tableTo": "users", + "columnsFrom": [ + "granted_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_admin_grants_revoked_by_user_id_users_id_fk": { + "name": "workspace_admin_grants_revoked_by_user_id_users_id_fk", + "tableFrom": "workspace_admin_grants", + "tableTo": "users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_admin_grants_revocation_pair": { + "name": "workspace_admin_grants_revocation_pair", + "value": "(\"workspace_admin_grants\".\"revoked_by_user_id\" is null) = (\"workspace_admin_grants\".\"revoked_at\" is null)" + }, + "workspace_admin_grants_revoked_after_granted": { + "name": "workspace_admin_grants_revoked_after_granted", + "value": "\"workspace_admin_grants\".\"revoked_at\" is null or \"workspace_admin_grants\".\"revoked_at\" >= \"workspace_admin_grants\".\"granted_at\"" + } + }, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_completed_at": { + "name": "setup_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_name_unique": { + "name": "workspaces_name_unique", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_identities": { + "name": "auth_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "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": { + "auth_identities_provider_subject_unique": { + "name": "auth_identities_provider_subject_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_identities_user_provider_unique": { + "name": "auth_identities_user_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_identities_issuer_subject_unique": { + "name": "auth_identities_issuer_subject_unique", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_identities_user_id_idx": { + "name": "auth_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_identities_user_id_users_id_fk": { + "name": "auth_identities_user_id_users_id_fk", + "tableFrom": "auth_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account_legacy_upgrades": { + "name": "account_legacy_upgrades", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "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()" + }, + "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.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_expires_at_idx": { + "name": "auth_sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_users_id_fk": { + "name": "auth_sessions_user_id_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_verifications_expires_at_idx": { + "name": "auth_verifications_expires_at_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.computer_connect_codes": { + "name": "computer_connect_codes", + "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 + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by_user_id": { + "name": "issued_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_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_workspace_computer_id": { + "name": "consumed_workspace_computer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "computer_connect_codes_workspace_created_idx": { + "name": "computer_connect_codes_workspace_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": {} + } + }, + "foreignKeys": { + "computer_connect_codes_workspace_id_workspaces_id_fk": { + "name": "computer_connect_codes_workspace_id_workspaces_id_fk", + "tableFrom": "computer_connect_codes", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "computer_connect_codes_issued_by_user_id_users_id_fk": { + "name": "computer_connect_codes_issued_by_user_id_users_id_fk", + "tableFrom": "computer_connect_codes", + "tableTo": "users", + "columnsFrom": [ + "issued_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "computer_connect_codes_revoked_by_user_id_users_id_fk": { + "name": "computer_connect_codes_revoked_by_user_id_users_id_fk", + "tableFrom": "computer_connect_codes", + "tableTo": "users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "computer_connect_codes_workspace_enrollment_fk": { + "name": "computer_connect_codes_workspace_enrollment_fk", + "tableFrom": "computer_connect_codes", + "tableTo": "workspace_computers", + "columnsFrom": [ + "workspace_id", + "consumed_workspace_computer_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "computer_connect_codes_token_hash_unique": { + "name": "computer_connect_codes_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "computer_connect_codes_expiry": { + "name": "computer_connect_codes_expiry", + "value": "\"computer_connect_codes\".\"expires_at\" > \"computer_connect_codes\".\"created_at\"" + }, + "computer_connect_codes_consumption_pair": { + "name": "computer_connect_codes_consumption_pair", + "value": "(\"computer_connect_codes\".\"consumed_workspace_computer_id\" is null) = (\"computer_connect_codes\".\"consumed_at\" is null)" + }, + "computer_connect_codes_revocation_pair": { + "name": "computer_connect_codes_revocation_pair", + "value": "(\"computer_connect_codes\".\"revoked_by_user_id\" is null) = (\"computer_connect_codes\".\"revoked_at\" is null)" + }, + "computer_connect_codes_terminal_state": { + "name": "computer_connect_codes_terminal_state", + "value": "not (\"computer_connect_codes\".\"consumed_at\" is not null and \"computer_connect_codes\".\"revoked_at\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.computers": { + "name": "computers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "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.workspace_computer_credentials": { + "name": "workspace_computer_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_computer_id": { + "name": "workspace_computer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by_user_id": { + "name": "issued_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_computer_credentials_active_enrollment_unique": { + "name": "workspace_computer_credentials_active_enrollment_unique", + "columns": [ + { + "expression": "workspace_computer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_computer_credentials\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_computer_credentials_enrollment_issued_idx": { + "name": "workspace_computer_credentials_enrollment_issued_idx", + "columns": [ + { + "expression": "workspace_computer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_computer_credentials_workspace_computer_id_workspace_computers_id_fk": { + "name": "workspace_computer_credentials_workspace_computer_id_workspace_computers_id_fk", + "tableFrom": "workspace_computer_credentials", + "tableTo": "workspace_computers", + "columnsFrom": [ + "workspace_computer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_computer_credentials_issued_by_user_id_users_id_fk": { + "name": "workspace_computer_credentials_issued_by_user_id_users_id_fk", + "tableFrom": "workspace_computer_credentials", + "tableTo": "users", + "columnsFrom": [ + "issued_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_computer_credentials_revoked_by_user_id_users_id_fk": { + "name": "workspace_computer_credentials_revoked_by_user_id_users_id_fk", + "tableFrom": "workspace_computer_credentials", + "tableTo": "users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_computer_credentials_secret_hash_unique": { + "name": "workspace_computer_credentials_secret_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "secret_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_computer_credentials_revocation_pair": { + "name": "workspace_computer_credentials_revocation_pair", + "value": "(\"workspace_computer_credentials\".\"revoked_by_user_id\" is null) = (\"workspace_computer_credentials\".\"revoked_at\" is null)" + }, + "workspace_computer_credentials_revoked_after_issued": { + "name": "workspace_computer_credentials_revoked_after_issued", + "value": "\"workspace_computer_credentials\".\"revoked_at\" is null or \"workspace_computer_credentials\".\"revoked_at\" >= \"workspace_computer_credentials\".\"issued_at\"" + } + }, + "isRLSEnabled": false + }, + "public.workspace_computers": { + "name": "workspace_computers", + "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 + }, + "computer_id": { + "name": "computer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "computer_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "arch": { + "name": "arch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_version": { + "name": "client_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enrolled_by_user_id": { + "name": "enrolled_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_instance_id": { + "name": "current_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_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": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_computers_active_workspace_computer_unique": { + "name": "workspace_computers_active_workspace_computer_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "computer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_computers\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_computers_active_workspace_idx": { + "name": "workspace_computers_active_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_computers\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_computers_computer_id_idx": { + "name": "workspace_computers_computer_id_idx", + "columns": [ + { + "expression": "computer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_computers_workspace_id_workspaces_id_fk": { + "name": "workspace_computers_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_computers", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_computers_computer_id_computers_id_fk": { + "name": "workspace_computers_computer_id_computers_id_fk", + "tableFrom": "workspace_computers", + "tableTo": "computers", + "columnsFrom": [ + "computer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_computers_enrolled_by_user_id_users_id_fk": { + "name": "workspace_computers_enrolled_by_user_id_users_id_fk", + "tableFrom": "workspace_computers", + "tableTo": "users", + "columnsFrom": [ + "enrolled_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_computers_revoked_by_user_id_users_id_fk": { + "name": "workspace_computers_revoked_by_user_id_users_id_fk", + "tableFrom": "workspace_computers", + "tableTo": "users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_computers_workspace_id_id_unique": { + "name": "workspace_computers_workspace_id_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_computers_revocation_pair": { + "name": "workspace_computers_revocation_pair", + "value": "(\"workspace_computers\".\"revoked_by_user_id\" is null) = (\"workspace_computers\".\"revoked_at\" is null)" + }, + "workspace_computers_revoked_after_enrolled": { + "name": "workspace_computers_revoked_after_enrolled", + "value": "\"workspace_computers\".\"revoked_at\" is null or \"workspace_computers\".\"revoked_at\" >= \"workspace_computers\".\"enrolled_at\"" + } + }, + "isRLSEnabled": false + }, + "public.im_bindings": { + "name": "im_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "im_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "im_binding_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "external_app_id": { + "name": "external_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_team_id": { + "name": "external_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_enterprise_id": { + "name": "external_enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_bot_id": { + "name": "external_bot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_team_brand": { + "name": "external_team_brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_team_name": { + "name": "external_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_display_name": { + "name": "bot_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_avatar_url": { + "name": "bot_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_schema_version": { + "name": "credential_schema_version", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "credential_generation": { + "name": "credential_generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "encrypted_credential": { + "name": "encrypted_credential", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_capabilities": { + "name": "granted_capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "setup_attempt_id": { + "name": "setup_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "setup_intent": { + "name": "setup_intent", + "type": "feishu_setup_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "feishu_setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "setup_owner_instance_id": { + "name": "setup_owner_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "setup_owner_heartbeat_at": { + "name": "setup_owner_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "encrypted_setup_context": { + "name": "encrypted_setup_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_expires_at": { + "name": "setup_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replacement_im_binding_id": { + "name": "replacement_im_binding_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_owner_instance_id": { + "name": "connection_owner_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connection_fencing_epoch": { + "name": "connection_fencing_epoch", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "connection_lease_expires_at": { + "name": "connection_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "observed_connected_at": { + "name": "observed_connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "im_bindings_agent_current_unique": { + "name": "im_bindings_agent_current_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_bindings\".\"status\" <> 'disabled'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_bindings_feishu_app_current_unique": { + "name": "im_bindings_feishu_app_current_unique", + "columns": [ + { + "expression": "external_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_bindings\".\"provider\" = 'feishu' and \"im_bindings\".\"status\" <> 'disabled'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_bindings_slack_app_team_current_unique": { + "name": "im_bindings_slack_app_team_current_unique", + "columns": [ + { + "expression": "external_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_bindings\".\"provider\" = 'slack' and \"im_bindings\".\"status\" <> 'disabled'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_bindings_agent_id_agents_id_fk": { + "name": "im_bindings_agent_id_agents_id_fk", + "tableFrom": "im_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "im_bindings_replacement_im_binding_id_im_bindings_id_fk": { + "name": "im_bindings_replacement_im_binding_id_im_bindings_id_fk", + "tableFrom": "im_bindings", + "tableTo": "im_bindings", + "columnsFrom": [ + "replacement_im_binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "im_bindings_credential_generation_nonnegative": { + "name": "im_bindings_credential_generation_nonnegative", + "value": "\"im_bindings\".\"credential_generation\" >= 0" + }, + "im_bindings_connection_epoch_nonnegative": { + "name": "im_bindings_connection_epoch_nonnegative", + "value": "\"im_bindings\".\"connection_fencing_epoch\" >= 0" + }, + "im_bindings_active_binding_shape": { + "name": "im_bindings_active_binding_shape", + "value": "\"im_bindings\".\"status\" not in ('active', 'reauthorization_required') or (\n \"im_bindings\".\"external_app_id\" is not null and\n (\"im_bindings\".\"provider\" = 'feishu' or \"im_bindings\".\"external_team_id\" is not null) and\n \"im_bindings\".\"external_bot_id\" is not null and \"im_bindings\".\"credential_schema_version\" is not null and\n \"im_bindings\".\"credential_generation\" >= 1 and \"im_bindings\".\"encrypted_credential\" is not null and\n \"im_bindings\".\"activated_at\" is not null and \"im_bindings\".\"disabled_at\" is null\n )" + }, + "im_bindings_disabled_secret_shape": { + "name": "im_bindings_disabled_secret_shape", + "value": "\"im_bindings\".\"status\" <> 'disabled' or (\n \"im_bindings\".\"encrypted_credential\" is null and \"im_bindings\".\"encrypted_setup_context\" is null and\n \"im_bindings\".\"setup_owner_instance_id\" is null and \"im_bindings\".\"connection_owner_instance_id\" is null and\n \"im_bindings\".\"connection_lease_expires_at\" is null and \"im_bindings\".\"disabled_at\" is not null\n )" + }, + "im_bindings_setup_owner_shape": { + "name": "im_bindings_setup_owner_shape", + "value": "(\"im_bindings\".\"setup_owner_instance_id\" is null and \"im_bindings\".\"setup_owner_heartbeat_at\" is null and\n \"im_bindings\".\"encrypted_setup_context\" is null and \"im_bindings\".\"setup_expires_at\" is null)\n or (\"im_bindings\".\"setup_attempt_id\" is not null and \"im_bindings\".\"setup_intent\" is not null and\n \"im_bindings\".\"setup_state\" is not null and \"im_bindings\".\"setup_owner_instance_id\" is not null and\n \"im_bindings\".\"setup_owner_heartbeat_at\" is not null and \"im_bindings\".\"encrypted_setup_context\" is not null and\n \"im_bindings\".\"setup_expires_at\" is not null)" + }, + "im_bindings_connection_owner_shape": { + "name": "im_bindings_connection_owner_shape", + "value": "(\"im_bindings\".\"connection_owner_instance_id\" is null and \"im_bindings\".\"connection_lease_expires_at\" is null)\n or (\"im_bindings\".\"provider\" = 'feishu' and \"im_bindings\".\"connection_owner_instance_id\" is not null and\n \"im_bindings\".\"connection_lease_expires_at\" is not null)" + }, + "im_bindings_slack_setup_fields_null": { + "name": "im_bindings_slack_setup_fields_null", + "value": "\"im_bindings\".\"provider\" <> 'slack' or (\n \"im_bindings\".\"setup_attempt_id\" is null and \"im_bindings\".\"setup_intent\" is null and\n \"im_bindings\".\"setup_state\" is null and \"im_bindings\".\"setup_owner_instance_id\" is null and\n \"im_bindings\".\"setup_owner_heartbeat_at\" is null and \"im_bindings\".\"encrypted_setup_context\" is null and\n \"im_bindings\".\"setup_expires_at\" is null\n )" + } + }, + "isRLSEnabled": false + }, + "public.im_message_deliveries": { + "name": "im_message_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attention": { + "name": "attention", + "type": "im_delivery_attention", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "im_delivery_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "placement_generation": { + "name": "placement_generation", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "dispatch_request_id": { + "name": "dispatch_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dispatch_input_hash": { + "name": "dispatch_input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispatch_payload": { + "name": "dispatch_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steer_target_delivery_id": { + "name": "steer_target_delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "steered_at": { + "name": "steered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "report_owner_instance_id": { + "name": "report_owner_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "result_hash": { + "name": "result_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turn_report": { + "name": "turn_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "im_message_deliveries_message_session_unique": { + "name": "im_message_deliveries_message_session_unique", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_message_deliveries_session_id_idx": { + "name": "im_message_deliveries_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_message_deliveries_steer_target_idx": { + "name": "im_message_deliveries_steer_target_idx", + "columns": [ + { + "expression": "steer_target_delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_message_deliveries_pending_idx": { + "name": "im_message_deliveries_pending_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_message_deliveries_dispatch_request_unique": { + "name": "im_message_deliveries_dispatch_request_unique", + "columns": [ + { + "expression": "dispatch_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_message_deliveries\".\"dispatch_request_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_message_deliveries_turn_id_unique": { + "name": "im_message_deliveries_turn_id_unique", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_message_deliveries\".\"turn_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_deliveries_message_id_im_messages_id_fk": { + "name": "im_message_deliveries_message_id_im_messages_id_fk", + "tableFrom": "im_message_deliveries", + "tableTo": "im_messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "im_message_deliveries_session_id_sessions_id_fk": { + "name": "im_message_deliveries_session_id_sessions_id_fk", + "tableFrom": "im_message_deliveries", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "im_message_deliveries_steer_target_delivery_id_im_message_deliveries_id_fk": { + "name": "im_message_deliveries_steer_target_delivery_id_im_message_deliveries_id_fk", + "tableFrom": "im_message_deliveries", + "tableTo": "im_message_deliveries", + "columnsFrom": [ + "steer_target_delivery_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "im_message_deliveries_dispatch_shape": { + "name": "im_message_deliveries_dispatch_shape", + "value": "(\"im_message_deliveries\".\"dispatch_request_id\" is null and \"im_message_deliveries\".\"dispatch_input_hash\" is null and \"im_message_deliveries\".\"dispatch_payload\" is null)\n or (\"im_message_deliveries\".\"dispatch_request_id\" is not null and \"im_message_deliveries\".\"dispatch_input_hash\" is not null\n and (\"im_message_deliveries\".\"dispatch_payload\" is not null or \"im_message_deliveries\".\"state\" = 'accepted'))" + }, + "im_message_deliveries_custody_shape": { + "name": "im_message_deliveries_custody_shape", + "value": "(\"im_message_deliveries\".\"state\" = 'accepted' and \"im_message_deliveries\".\"input_hash\" is not null and \"im_message_deliveries\".\"turn_id\" is not null\n and \"im_message_deliveries\".\"report_owner_instance_id\" is not null and \"im_message_deliveries\".\"accepted_at\" is not null\n and \"im_message_deliveries\".\"steer_target_delivery_id\" is null and \"im_message_deliveries\".\"steered_at\" is null)\n or (\"im_message_deliveries\".\"state\" = 'steered' and \"im_message_deliveries\".\"input_hash\" is not null and \"im_message_deliveries\".\"steer_target_delivery_id\" is not null\n and \"im_message_deliveries\".\"steered_at\" is not null and \"im_message_deliveries\".\"turn_id\" is null and \"im_message_deliveries\".\"report_owner_instance_id\" is null\n and \"im_message_deliveries\".\"accepted_at\" is null and \"im_message_deliveries\".\"reported_at\" is null and \"im_message_deliveries\".\"turn_report\" is null\n and \"im_message_deliveries\".\"result_hash\" is null)\n or (\"im_message_deliveries\".\"state\" not in ('accepted', 'steered') and \"im_message_deliveries\".\"input_hash\" is null and \"im_message_deliveries\".\"turn_id\" is null\n and \"im_message_deliveries\".\"report_owner_instance_id\" is null and \"im_message_deliveries\".\"accepted_at\" is null and \"im_message_deliveries\".\"steered_at\" is null\n and \"im_message_deliveries\".\"reported_at\" is null and \"im_message_deliveries\".\"turn_report\" is null and \"im_message_deliveries\".\"result_hash\" is null)" + }, + "im_message_deliveries_report_shape": { + "name": "im_message_deliveries_report_shape", + "value": "(\"im_message_deliveries\".\"reported_at\" is null and \"im_message_deliveries\".\"turn_report\" is null)\n or (\"im_message_deliveries\".\"reported_at\" is not null and \"im_message_deliveries\".\"turn_report\" is not null and \"im_message_deliveries\".\"result_hash\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.im_messages": { + "name": "im_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "im_binding_id": { + "name": "im_binding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_message_id": { + "name": "external_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_revision_key": { + "name": "provider_revision_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "im_message_operation", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "im_message_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "thread_key": { + "name": "thread_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to_external_id": { + "name": "reply_to_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_kind": { + "name": "author_kind", + "type": "im_author_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_external_id": { + "name": "author_external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_display_name": { + "name": "author_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "provider_context": { + "name": "provider_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_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()" + } + }, + "indexes": { + "im_messages_provider_event_unique": { + "name": "im_messages_provider_event_unique", + "columns": [ + { + "expression": "im_binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_messages\".\"provider_event_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_messages_semantic_revision_unique": { + "name": "im_messages_semantic_revision_unique", + "columns": [ + { + "expression": "im_binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_revision_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_messages_scope_occurred_idx": { + "name": "im_messages_scope_occurred_idx", + "columns": [ + { + "expression": "im_binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "im_messages_external_occurred_idx": { + "name": "im_messages_external_occurred_idx", + "columns": [ + { + "expression": "im_binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_messages_im_binding_id_im_bindings_id_fk": { + "name": "im_messages_im_binding_id_im_bindings_id_fk", + "tableFrom": "im_messages", + "tableTo": "im_bindings", + "columnsFrom": [ + "im_binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_invitations": { + "name": "admin_invitations", + "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 + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_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 + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "admin_invitations_workspace_created_idx": { + "name": "admin_invitations_workspace_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": {} + } + }, + "foreignKeys": { + "admin_invitations_workspace_id_workspaces_id_fk": { + "name": "admin_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "admin_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "admin_invitations_created_by_user_id_users_id_fk": { + "name": "admin_invitations_created_by_user_id_users_id_fk", + "tableFrom": "admin_invitations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "admin_invitations_accepted_by_user_id_users_id_fk": { + "name": "admin_invitations_accepted_by_user_id_users_id_fk", + "tableFrom": "admin_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "admin_invitations_revoked_by_user_id_users_id_fk": { + "name": "admin_invitations_revoked_by_user_id_users_id_fk", + "tableFrom": "admin_invitations", + "tableTo": "users", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_invitations_token_hash_unique": { + "name": "admin_invitations_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "admin_invitations_expiry": { + "name": "admin_invitations_expiry", + "value": "\"admin_invitations\".\"expires_at\" > \"admin_invitations\".\"created_at\"" + }, + "admin_invitations_acceptance_pair": { + "name": "admin_invitations_acceptance_pair", + "value": "(\"admin_invitations\".\"accepted_by_user_id\" is null) = (\"admin_invitations\".\"accepted_at\" is null)" + }, + "admin_invitations_revocation_pair": { + "name": "admin_invitations_revocation_pair", + "value": "(\"admin_invitations\".\"revoked_by_user_id\" is null) = (\"admin_invitations\".\"revoked_at\" is null)" + }, + "admin_invitations_terminal_state": { + "name": "admin_invitations_terminal_state", + "value": "not (\"admin_invitations\".\"accepted_at\" is not null and \"admin_invitations\".\"revoked_at\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.session_cli_proofs": { + "name": "session_cli_proofs", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "proof_id": { + "name": "proof_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_computer_id": { + "name": "workspace_computer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "placement_generation": { + "name": "placement_generation", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "connection_instance_id": { + "name": "connection_instance_id", + "type": "uuid", + "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": {}, + "foreignKeys": { + "session_cli_proofs_session_id_sessions_id_fk": { + "name": "session_cli_proofs_session_id_sessions_id_fk", + "tableFrom": "session_cli_proofs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_cli_proofs_workspace_computer_id_workspace_computers_id_fk": { + "name": "session_cli_proofs_workspace_computer_id_workspace_computers_id_fk", + "tableFrom": "session_cli_proofs", + "tableTo": "workspace_computers", + "columnsFrom": [ + "workspace_computer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_cli_proofs_proof_id_unique": { + "name": "session_cli_proofs_proof_id_unique", + "nullsNotDistinct": false, + "columns": [ + "proof_id" + ] + }, + "session_cli_proofs_token_hash_unique": { + "name": "session_cli_proofs_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "session_cli_proofs_token_hash_shape": { + "name": "session_cli_proofs_token_hash_shape", + "value": "\"session_cli_proofs\".\"token_hash\" ~ '^[0-9a-f]{64}$'" + }, + "session_cli_proofs_generation_positive": { + "name": "session_cli_proofs_generation_positive", + "value": "\"session_cli_proofs\".\"placement_generation\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.session_messages": { + "name": "session_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "source_session_id": { + "name": "source_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_session_id": { + "name": "target_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "session_message_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_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": { + "session_messages_target_created_idx": { + "name": "session_messages_target_created_idx", + "columns": [ + { + "expression": "target_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_messages_source_created_idx": { + "name": "session_messages_source_created_idx", + "columns": [ + { + "expression": "source_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_messages_source_session_id_sessions_id_fk": { + "name": "session_messages_source_session_id_sessions_id_fk", + "tableFrom": "session_messages", + "tableTo": "sessions", + "columnsFrom": [ + "source_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_messages_target_session_id_sessions_id_fk": { + "name": "session_messages_target_session_id_sessions_id_fk", + "tableFrom": "session_messages", + "tableTo": "sessions", + "columnsFrom": [ + "target_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_messages_content_hash_shape": { + "name": "session_messages_content_hash_shape", + "value": "\"session_messages\".\"content_hash\" ~ '^[0-9a-f]{64}$'" + }, + "session_messages_content_bounds": { + "name": "session_messages_content_bounds", + "value": "octet_length(\"session_messages\".\"content\") between 1 and 16384" + }, + "session_messages_error_code_shape": { + "name": "session_messages_error_code_shape", + "value": "\"session_messages\".\"last_error_code\" is null or (\"session_messages\".\"last_error_code\" ~ '^[a-z][a-z0-9_]{0,127}$')" + }, + "session_messages_attempt_count_nonnegative": { + "name": "session_messages_attempt_count_nonnegative", + "value": "\"session_messages\".\"attempt_count\" >= 0" + }, + "session_messages_attempt_shape": { + "name": "session_messages_attempt_shape", + "value": "(\"session_messages\".\"attempt_count\" = 0 and \"session_messages\".\"last_attempt_at\" is null)\n or (\"session_messages\".\"attempt_count\" > 0 and \"session_messages\".\"last_attempt_at\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.session_descendants": { + "name": "session_descendants", + "schema": "", + "columns": { + "ancestor_session_id": { + "name": "ancestor_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "descendant_session_id": { + "name": "descendant_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_message_created_at": { + "name": "last_message_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_message_id": { + "name": "last_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_delivery_outcome": { + "name": "last_delivery_outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_preview": { + "name": "task_preview", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_descendants_ancestor_activity_idx": { + "name": "session_descendants_ancestor_activity_idx", + "columns": [ + { + "expression": "ancestor_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "descendant_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_descendants_ancestor_depth_activity_idx": { + "name": "session_descendants_ancestor_depth_activity_idx", + "columns": [ + { + "expression": "ancestor_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depth", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "descendant_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_descendants_descendant_ancestor_idx": { + "name": "session_descendants_descendant_ancestor_idx", + "columns": [ + { + "expression": "descendant_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ancestor_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_descendants_last_message_idx": { + "name": "session_descendants_last_message_idx", + "columns": [ + { + "expression": "last_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_descendants_ancestor_session_id_sessions_id_fk": { + "name": "session_descendants_ancestor_session_id_sessions_id_fk", + "tableFrom": "session_descendants", + "tableTo": "sessions", + "columnsFrom": [ + "ancestor_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_descendants_descendant_session_id_sessions_id_fk": { + "name": "session_descendants_descendant_session_id_sessions_id_fk", + "tableFrom": "session_descendants", + "tableTo": "sessions", + "columnsFrom": [ + "descendant_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_descendants_ancestor_session_id_descendant_session_id_pk": { + "name": "session_descendants_ancestor_session_id_descendant_session_id_pk", + "columns": [ + "ancestor_session_id", + "descendant_session_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_descendants_depth_positive": { + "name": "session_descendants_depth_positive", + "value": "\"session_descendants\".\"depth\" >= 1" + }, + "session_descendants_outcome_valid": { + "name": "session_descendants_outcome_valid", + "value": "\"session_descendants\".\"last_delivery_outcome\" in ('accepted', 'unreachable', 'unknown', 'rejected')" + }, + "session_descendants_preview_bounds": { + "name": "session_descendants_preview_bounds", + "value": "char_length(\"session_descendants\".\"task_preview\") between 1 and 256" + } + }, + "isRLSEnabled": false + }, + "public.session_placements": { + "name": "session_placements", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "workspace_computer_id": { + "name": "workspace_computer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_placements_workspace_computer_id_idx": { + "name": "session_placements_workspace_computer_id_idx", + "columns": [ + { + "expression": "workspace_computer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_placements_session_id_sessions_id_fk": { + "name": "session_placements_session_id_sessions_id_fk", + "tableFrom": "session_placements", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_placements_workspace_computer_id_workspace_computers_id_fk": { + "name": "session_placements_workspace_computer_id_workspace_computers_id_fk", + "tableFrom": "session_placements", + "tableTo": "workspace_computers", + "columnsFrom": [ + "workspace_computer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "session_placements_generation_positive": { + "name": "session_placements_generation_positive", + "value": "\"session_placements\".\"generation\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "im_binding_id": { + "name": "im_binding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "im_conversation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "session_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "thread_key": { + "name": "thread_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_session_id": { + "name": "created_by_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_model": { + "name": "runtime_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_reasoning_effort": { + "name": "runtime_reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_max_duration_ms": { + "name": "runtime_max_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_active_channel_unique": { + "name": "sessions_active_channel_unique", + "columns": [ + { + "expression": "im_binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sessions\".\"kind\" = 'channel' and \"sessions\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_active_thread_unique": { + "name": "sessions_active_thread_unique", + "columns": [ + { + "expression": "im_binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sessions\".\"kind\" = 'thread' and \"sessions\".\"ended_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_im_binding_scope_idx": { + "name": "sessions_im_binding_scope_idx", + "columns": [ + { + "expression": "im_binding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_creator_created_idx": { + "name": "sessions_creator_created_idx", + "columns": [ + { + "expression": "created_by_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_im_binding_id_im_bindings_id_fk": { + "name": "sessions_im_binding_id_im_bindings_id_fk", + "tableFrom": "sessions", + "tableTo": "im_bindings", + "columnsFrom": [ + "im_binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sessions_created_by_session_id_sessions_id_fk": { + "name": "sessions_created_by_session_id_sessions_id_fk", + "tableFrom": "sessions", + "tableTo": "sessions", + "columnsFrom": [ + "created_by_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sessions_shape_check": { + "name": "sessions_shape_check", + "value": "(\"sessions\".\"kind\" = 'channel' and \"sessions\".\"thread_key\" is null and \"sessions\".\"created_by_session_id\" is null and \"sessions\".\"runtime_model\" is null and \"sessions\".\"runtime_reasoning_effort\" is null and \"sessions\".\"runtime_max_duration_ms\" is null)\n or (\"sessions\".\"kind\" = 'thread' and \"sessions\".\"thread_key\" is not null and \"sessions\".\"created_by_session_id\" is null and \"sessions\".\"runtime_model\" is null and \"sessions\".\"runtime_reasoning_effort\" is null and \"sessions\".\"runtime_max_duration_ms\" is null)\n or (\"sessions\".\"kind\" = 'internal' and \"sessions\".\"created_by_session_id\" is not null)" + }, + "sessions_runtime_max_duration_valid": { + "name": "sessions_runtime_max_duration_valid", + "value": "\"sessions\".\"runtime_max_duration_ms\" is null or (\"sessions\".\"runtime_max_duration_ms\" > 0 and \"sessions\".\"runtime_max_duration_ms\" <= 86400000)" + }, + "sessions_runtime_model_bounds": { + "name": "sessions_runtime_model_bounds", + "value": "\"sessions\".\"runtime_model\" is null or octet_length(\"sessions\".\"runtime_model\") between 1 and 128" + }, + "sessions_runtime_reasoning_effort_bounds": { + "name": "sessions_runtime_reasoning_effort_bounds", + "value": "\"sessions\".\"runtime_reasoning_effort\" is null or octet_length(\"sessions\".\"runtime_reasoning_effort\") between 1 and 64" + }, + "sessions_revision_positive": { + "name": "sessions_revision_positive", + "value": "\"sessions\".\"revision\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.slack_oauth_nonces": { + "name": "slack_oauth_nonces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "nonce_hash": { + "name": "nonce_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expected_binding_id": { + "name": "expected_binding_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "expected_credential_generation": { + "name": "expected_credential_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "session_binding_hash": { + "name": "session_binding_hash", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_oauth_nonces_user_agent_idx": { + "name": "slack_oauth_nonces_user_agent_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_oauth_nonces_expires_idx": { + "name": "slack_oauth_nonces_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_oauth_nonces_user_id_users_id_fk": { + "name": "slack_oauth_nonces_user_id_users_id_fk", + "tableFrom": "slack_oauth_nonces", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "slack_oauth_nonces_agent_id_agents_id_fk": { + "name": "slack_oauth_nonces_agent_id_agents_id_fk", + "tableFrom": "slack_oauth_nonces", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_oauth_nonces_nonce_hash_unique": { + "name": "slack_oauth_nonces_nonce_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "nonce_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "slack_oauth_nonces_intent": { + "name": "slack_oauth_nonces_intent", + "value": "\"slack_oauth_nonces\".\"intent\" in ('create', 'reauthorize', 'replace')" + }, + "slack_oauth_nonces_expiry": { + "name": "slack_oauth_nonces_expiry", + "value": "\"slack_oauth_nonces\".\"expires_at\" > \"slack_oauth_nonces\".\"created_at\"" + }, + "slack_oauth_nonces_expected_binding_pair": { + "name": "slack_oauth_nonces_expected_binding_pair", + "value": "(\"slack_oauth_nonces\".\"expected_binding_id\" is null) = (\"slack_oauth_nonces\".\"expected_credential_generation\" is null)" + }, + "slack_oauth_nonces_generation_positive": { + "name": "slack_oauth_nonces_generation_positive", + "value": "\"slack_oauth_nonces\".\"expected_credential_generation\" is null or \"slack_oauth_nonces\".\"expected_credential_generation\" >= 1" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_receive_mode": { + "name": "agent_receive_mode", + "schema": "public", + "values": [ + "all_message", + "mention_only" + ] + }, + "public.agent_runtime_provider": { + "name": "agent_runtime_provider", + "schema": "public", + "values": [ + "codex", + "claude-code" + ] + }, + "public.agent_status": { + "name": "agent_status", + "schema": "public", + "values": [ + "active", + "suspended", + "deleted" + ] + }, + "public.computer_platform": { + "name": "computer_platform", + "schema": "public", + "values": [ + "darwin", + "linux", + "win32" + ] + }, + "public.feishu_setup_intent": { + "name": "feishu_setup_intent", + "schema": "public", + "values": [ + "create", + "reauthorize", + "replace" + ] + }, + "public.feishu_setup_state": { + "name": "feishu_setup_state", + "schema": "public", + "values": [ + "awaiting_user", + "validating", + "succeeded", + "failed", + "expired", + "canceled" + ] + }, + "public.im_binding_status": { + "name": "im_binding_status", + "schema": "public", + "values": [ + "provisioning", + "active", + "reauthorization_required", + "error", + "disabled" + ] + }, + "public.im_conversation_kind": { + "name": "im_conversation_kind", + "schema": "public", + "values": [ + "channel", + "dm", + "group_dm" + ] + }, + "public.im_provider": { + "name": "im_provider", + "schema": "public", + "values": [ + "feishu", + "slack" + ] + }, + "public.im_author_kind": { + "name": "im_author_kind", + "schema": "public", + "values": [ + "human", + "bot", + "system" + ] + }, + "public.im_delivery_attention": { + "name": "im_delivery_attention", + "schema": "public", + "values": [ + "direct", + "ambient" + ] + }, + "public.im_delivery_state": { + "name": "im_delivery_state", + "schema": "public", + "values": [ + "pending", + "accepted", + "steered", + "terminal_rejected", + "expired" + ] + }, + "public.im_message_direction": { + "name": "im_message_direction", + "schema": "public", + "values": [ + "inbound", + "outbound" + ] + }, + "public.im_message_operation": { + "name": "im_message_operation", + "schema": "public", + "values": [ + "created", + "edited", + "deleted" + ] + }, + "public.session_message_outcome": { + "name": "session_message_outcome", + "schema": "public", + "values": [ + "unknown", + "accepted", + "unreachable", + "rejected" + ] + }, + "public.session_kind": { + "name": "session_kind", + "schema": "public", + "values": [ + "channel", + "thread", + "internal" + ] + } + }, + "schemas": {}, + "sequences": { + "public.runtime_config_revision_sequence": { + "name": "runtime_config_revision_sequence", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9007199254740991", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index 3af27502..5261b868 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1787841453286, "tag": "0022_short_kitty_pryde", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1787870418663, + "tag": "0023_motionless_gideon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/__tests__/auth-api.test.ts b/packages/server/src/__tests__/auth-api.test.ts index 378e81de..0f7c2c23 100644 --- a/packages/server/src/__tests__/auth-api.test.ts +++ b/packages/server/src/__tests__/auth-api.test.ts @@ -170,6 +170,7 @@ describe("auth HTTP API", () => { browserAuth: { publicOrigin: "https://dev.example.com", refreshTokenTtlSeconds: 3600, + sessionTtlSeconds: 3600, secureCookies: true, }, }); @@ -245,6 +246,7 @@ describe("auth HTTP API", () => { browserAuth: { publicOrigin: "https://dev.example.com", refreshTokenTtlSeconds: 3600, + sessionTtlSeconds: 3600, secureCookies: true, }, connectCode: { environment: "staging", issuer, publicUrl: "https://dev.example.com" }, diff --git a/packages/server/src/__tests__/better-auth-surface.test.ts b/packages/server/src/__tests__/better-auth-surface.test.ts index 44b7ae7d..d77c589b 100644 --- a/packages/server/src/__tests__/better-auth-surface.test.ts +++ b/packages/server/src/__tests__/better-auth-surface.test.ts @@ -54,6 +54,11 @@ describe("published Better Auth surface", () => { */ const unpublished = [ { method: "POST" as const, url: "/api/v1/auth/update-user" }, + // Mints a session with no credential at all. It exists only on a loopback development server, and even there + // nothing but the fenced OpenTag route may reach it. + { method: "POST" as const, url: "/api/v1/auth/dev/sign-in" }, + // Reachable only through the route that owns the refresh cookie, and only for as long as legacy credentials do. + { method: "POST" as const, url: "/api/v1/auth/legacy/upgrade" }, { method: "POST" as const, url: "/api/v1/auth/sign-in/social" }, { method: "POST" as const, url: "/api/v1/auth/sign-out" }, { method: "GET" as const, url: "/api/v1/auth/get-session" }, @@ -131,6 +136,7 @@ describe("published Better Auth surface", () => { betterAuth: { instance, publicUrl: "https://opentag.example.com" }, publicOrigin: "https://opentag.example.com", refreshTokenTtlSeconds: 3600, + sessionTtlSeconds: 3600, secureCookies: true, }, }); diff --git a/packages/server/src/__tests__/browser-auth.test.ts b/packages/server/src/__tests__/browser-auth.test.ts index d7b8235c..2e3b8cc1 100644 --- a/packages/server/src/__tests__/browser-auth.test.ts +++ b/packages/server/src/__tests__/browser-auth.test.ts @@ -1,12 +1,8 @@ import { HTTP_PATHS } from "@opentag/shared"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "../app.js"; -import { - AuthServiceError, - type DevBrowserAuthService, - type GoogleBrowserAuthService, - type UserAuthService, -} from "../services/auth/index.js"; +import type { OpenTagBetterAuth } from "../auth/better-auth.js"; +import { AuthServiceError, type GoogleBrowserAuthService, type UserAuthService } from "../services/auth/index.js"; const apps: ReturnType[] = []; afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close()))); @@ -59,29 +55,49 @@ function failAfterVerification(error: Error) { }; } -function dev() { - return { - signIn: vi.fn().mockResolvedValue({ - accessToken: "dev-access-secret", - refreshToken: "dev-refresh-secret", - tokenType: "Bearer", - expiresIn: 900, - }), - }; +/** Stands in for the Better Auth mount, recording which endpoint a route drove rather than guessing from its effects. */ +function betterAuthStub(reply: () => Response) { + const paths: string[] = []; + const handler = vi.fn(async (request: Request) => { + paths.push(new URL(request.url).pathname); + return reply(); + }); + const instance = { + $context: Promise.resolve({ authCookies: { sessionToken: { name: "opentag.session_token" } } }), + api: { getSession: vi.fn().mockResolvedValue(null) }, + handler, + } as unknown as OpenTagBetterAuth; + return { instance: { instance, publicUrl: "http://localhost:8000" }, paths }; +} + +function devSession() { + return new Response(JSON.stringify({ userId: "53e2babe-e4ac-4e2c-b7d1-d092d5a4568e" }), { + status: 200, + headers: { + "content-type": "application/json", + "set-cookie": "opentag.session_token=dev-session; Path=/; HttpOnly", + }, + }); } function createBrowserApp( - options: { devService?: ReturnType; googleService?: ReturnType | null } = {}, + options: { + betterAuth?: { instance: OpenTagBetterAuth; publicUrl: string }; + devSignIn?: boolean; + googleService?: ReturnType | null; + } = {}, ) { const googleService = options.googleService === null ? undefined : (options.googleService ?? google()); const auth = authService(); const app = createApp({ authService: auth, + ...(options.betterAuth ? { betterAuth: options.betterAuth } : {}), browserAuth: { - ...(options.devService ? { dev: options.devService as unknown as DevBrowserAuthService } : {}), + ...(options.devSignIn ? { devSignIn: true } : {}), ...(googleService ? { google: googleService as unknown as GoogleBrowserAuthService } : {}), publicOrigin: "http://localhost:8000", refreshTokenTtlSeconds: 3600, + sessionTtlSeconds: 3600, secureCookies: false, }, }); @@ -106,8 +122,8 @@ describe("browser authentication routes", () => { }); it("signs in the configured development user only from a loopback request", async () => { - const devService = dev(); - const { app } = createBrowserApp({ devService, googleService: null }); + const betterAuth = betterAuthStub(devSession); + const { app } = createBrowserApp({ betterAuth: betterAuth.instance, devSignIn: true, googleService: null }); expect((await app.inject({ method: "GET", url: HTTP_PATHS.authProviders })).json()).toEqual({ providers: [ { id: "google", enabled: false, startUrl: null }, @@ -133,8 +149,17 @@ describe("browser authentication routes", () => { }); expect(response.statusCode).toBe(302); expect(response.headers.location).toBe("/agents"); - expect(String(response.headers["set-cookie"])).toContain("opentag_access=dev-access-secret"); - expect(devService.signIn).toHaveBeenCalledOnce(); + expect(betterAuth.paths).toEqual(["/api/v1/auth/dev/sign-in"]); + const cookies = String(response.headers["set-cookie"]); + expect(cookies).toContain("opentag.session_token=dev-session"); + // Without the double-submit token a signed-in development browser could read but never write, sign-out included. + expect(cookies).toContain("opentag_csrf="); + /* + * The credential must live only in Better Auth's own cookie. A session token written into `opentag_access` still + * authenticates through the legacy fallback, which is what makes the mistake invisible — but `getSession` cannot + * see it, so sign-out has nothing to revoke and the session outlives the logout that claimed to end it. + */ + expect(cookies).not.toContain("opentag_access="); for (const request of [ { headers: { host: "localhost:8000" }, remoteAddress: "192.0.2.10" }, @@ -143,12 +168,12 @@ describe("browser authentication routes", () => { const rejected = await app.inject({ method: "GET", url: HTTP_PATHS.authDevCallback, ...request }); expect(rejected.statusCode).toBe(404); } - expect(devService.signIn).toHaveBeenCalledOnce(); + expect(betterAuth.paths).toEqual(["/api/v1/auth/dev/sign-in"]); }); it("rejects an external development redirect before issuing credentials", async () => { - const devService = dev(); - const { app } = createBrowserApp({ devService, googleService: null }); + const betterAuth = betterAuthStub(devSession); + const { app } = createBrowserApp({ betterAuth: betterAuth.instance, devSignIn: true, googleService: null }); const response = await app.inject({ method: "GET", url: `${HTTP_PATHS.authDevCallback}?next=${encodeURIComponent("https://example.com")}`, @@ -156,7 +181,21 @@ describe("browser authentication routes", () => { remoteAddress: "127.0.0.1", }); expect(response.statusCode).toBe(400); - expect(devService.signIn).not.toHaveBeenCalled(); + expect(betterAuth.paths).toEqual([]); + }); + + it("reports an unresolvable development user instead of redirecting to a signed-out page", async () => { + const betterAuth = betterAuthStub(() => new Response(JSON.stringify({ message: "gone" }), { status: 503 })); + const { app } = createBrowserApp({ betterAuth: betterAuth.instance, devSignIn: true, googleService: null }); + const response = await app.inject({ + method: "GET", + url: `${HTTP_PATHS.authDevCallback}?next=%2Fagents`, + headers: { host: "localhost:8000" }, + remoteAddress: "127.0.0.1", + }); + expect(response.statusCode).toBe(503); + expect(response.json()).toMatchObject({ error: { code: "AUTH_DEV_USER_UNAVAILABLE" } }); + expect(response.headers["set-cookie"]).toBeUndefined(); }); it("sets HttpOnly browser tokens only in cookies after a verified callback", async () => { @@ -312,4 +351,68 @@ describe("browser authentication routes", () => { expect(refreshed.statusCode).toBe(204); expect(auth.refresh).toHaveBeenCalledWith("refresh"); }); + + it("spends a legacy refresh on a Better Auth session rather than another legacy pair", async () => { + const betterAuth = betterAuthStub( + () => + new Response(JSON.stringify({ userId: "53e2babe-e4ac-4e2c-b7d1-d092d5a4568e" }), { + status: 200, + headers: { + "content-type": "application/json", + "set-cookie": "opentag.session_token=upgraded; Path=/; HttpOnly", + }, + }), + ); + const { app, auth } = createBrowserApp({ betterAuth: betterAuth.instance }); + + const response = await app.inject({ + method: "POST", + url: HTTP_PATHS.authBrowserRefresh, + headers: { + cookie: "opentag_refresh=legacy-refresh; opentag_csrf=csrf", + origin: "http://localhost:8000", + "x-opentag-csrf": "csrf", + }, + }); + + expect(response.statusCode).toBe(204); + expect(betterAuth.paths).toEqual(["/api/v1/auth/legacy/upgrade"]); + // Reissuing through the legacy provider would leave the browser on a credential stage 5 removes. + expect(auth.refresh).not.toHaveBeenCalled(); + const cookies = response.headers["set-cookie"] as string[]; + expect(cookies.find((value) => value.startsWith("opentag.session_token="))).toContain("upgraded"); + expect(cookies.find((value) => value.startsWith("opentag_csrf="))).toBeDefined(); + expect(cookies.filter((value) => /^opentag_(access|refresh)=;/.test(value))).toHaveLength(2); + }); + + it("keeps the legacy credentials usable when the upgrade is refused", async () => { + const betterAuth = betterAuthStub( + () => + new Response(JSON.stringify({ code: "AUTH_USER_SUSPENDED", message: "The user account is suspended" }), { + status: 403, + headers: { "content-type": "application/json" }, + }), + ); + const { app } = createBrowserApp({ betterAuth: betterAuth.instance }); + + const response = await app.inject({ + method: "POST", + url: HTTP_PATHS.authBrowserRefresh, + headers: { + cookie: "opentag_refresh=legacy-refresh; opentag_csrf=csrf", + origin: "http://localhost:8000", + "x-opentag-csrf": "csrf", + }, + }); + + /* + * The reason has to survive the crossing. Better Auth answers in its own shape, so forwarding it verbatim would + * reach the client as an unrecognized failure and be flattened to `AUTH_INVALID_TOKEN` — a suspended Account would + * be told to sign in again, and would keep being told that. + */ + expect(response.statusCode).toBe(403); + expect(response.json()).toMatchObject({ error: { code: "AUTH_USER_SUSPENDED" } }); + // Nothing was retired, so the browser can still retry once whatever refused it is resolved. + expect(response.headers["set-cookie"]).toBeUndefined(); + }); }); diff --git a/packages/server/src/__tests__/integration/auth-migrations.test.ts b/packages/server/src/__tests__/integration/auth-migrations.test.ts index 4b510ffa..cf778a71 100644 --- a/packages/server/src/__tests__/integration/auth-migrations.test.ts +++ b/packages/server/src/__tests__/integration/auth-migrations.test.ts @@ -108,10 +108,12 @@ describe("database migrations", () => { entries: Array<{ idx: number; tag: string }>; }; - expect(journal.entries.slice(20, 23).map(({ idx, tag }) => ({ idx, tag }))).toEqual([ + expect(journal.entries.slice(20, 24).map(({ idx, tag }) => ({ idx, tag }))).toEqual([ { idx: 20, tag: "0020_large_jack_power" }, { idx: 21, tag: "0021_slow_gamora" }, { idx: 22, tag: "0022_short_kitty_pryde" }, + // Records which session a legacy credential was exchanged for, so a replayed exchange converges on one row. + { idx: 23, tag: "0023_motionless_gideon" }, ]); }); @@ -1080,6 +1082,7 @@ describe("authentication persistence", () => { } return delegate.issuePairForUser(userId); }, + rotate: (token, userId) => delegate.rotate(token, userId), verifyAccess: (token) => delegate.verifyAccess(token), verifyRefresh: (token) => delegate.verifyRefresh(token), }; diff --git a/packages/server/src/__tests__/integration/better-auth.test.ts b/packages/server/src/__tests__/integration/better-auth.test.ts index 4816e437..0cb18533 100644 --- a/packages/server/src/__tests__/integration/better-auth.test.ts +++ b/packages/server/src/__tests__/integration/better-auth.test.ts @@ -1,17 +1,43 @@ import { randomUUID } from "node:crypto"; -import { and, eq, isNull } from "drizzle-orm"; -import type { FastifyRequest } from "fastify"; +import { and, eq, isNull, sql } from "drizzle-orm"; +import type { FastifyReply, FastifyRequest } from "fastify"; import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { bootstrapInitialAdmin } from "../../admin/bootstrap.js"; import { createBetterAuth } from "../../auth/better-auth.js"; +import { BetterAuthSessionTokens, BridgedSessionTokens } from "../../auth/session-tokens.js"; import { createDatabaseClient } from "../../db/client.js"; -import { authIdentities, authSessions, users, workspaceAdminGrants } from "../../db/schema/index.js"; -import { resolveAuthenticatedUserId } from "../../plugins/user-auth.js"; -import { AuthService, PostAuthenticationService } from "../../services/auth/index.js"; +import { + accountLegacyUpgrades, + authIdentities, + authSessions, + users, + workspaceAdminGrants, +} from "../../db/schema/index.js"; +import { createUserAuthPreHandler, resolveAuthenticatedUserId } from "../../plugins/user-auth.js"; +import { + AuthService, + AuthTokenService, + DevBrowserAuthService, + PostAuthenticationService, +} from "../../services/auth/index.js"; import { WorkspaceAdminAccess } from "../../services/workspace-admin-access/index.js"; import { type MigratedTestDatabase, startMigratedTestDatabase } from "./migrated-test-database.js"; const GOOGLE_ISSUER = "https://accounts.google.com"; const PUBLIC_URL = "http://localhost:8000"; +const LEGACY_SECRET = "legacy-jwt-secret-of-at-least-32-characters"; +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; + +/** The composition the server runs: Better Auth issues, and credentials it did not issue still verify. */ +function bridgedAuthService(auth: ReturnType): AuthService { + return new AuthService( + client.database, + new BridgedSessionTokens( + new BetterAuthSessionTokens(auth, client.database), + new AuthTokenService(LEGACY_SECRET, 900, 3600), + ), + ); +} let testDatabase: MigratedTestDatabase; let client: ReturnType; @@ -32,16 +58,65 @@ beforeEach(async () => { await testDatabase.reset(); }); -function createAuth() { +function createAuth( + devSignIn?: () => Promise, + legacyUpgrade?: (refreshToken: string) => Promise<{ expiresAt: Date; userId: string }>, +) { return createBetterAuth(client.database, { onSessionCreating: (userId) => postAuthentication.ensureAccountReady(userId).then(() => undefined), publicUrl: PUBLIC_URL, secret: "better-auth-integration-secret-at-least-32-characters", secureCookies: false, + sessionTtlSeconds: SESSION_TTL_SECONDS, + ...(devSignIn ? { devSignIn } : {}), + ...(legacyUpgrade ? { legacyUpgrade: { recordExchange, resolveCredential: legacyUpgrade } } : {}), google: { clientId: "google-client-id", clientSecret: "google-client-secret" }, }); } +/** The single-statement gate the composition root supplies, so a raced exchange converges here as it does in production. */ +async function recordExchange({ + expiresAt, + sessionToken, + tokenHash, +}: { + expiresAt: Date; + sessionToken: string; + tokenHash: string; +}): Promise { + const [recorded] = await client.database + .insert(accountLegacyUpgrades) + .values({ expiresAt, sessionToken, tokenHash }) + .onConflictDoUpdate({ + target: accountLegacyUpgrades.tokenHash, + set: { tokenHash: sql`${accountLegacyUpgrades.tokenHash}` }, + }) + .returning({ winner: accountLegacyUpgrades.sessionToken }); + if (!recorded) throw new Error("The legacy upgrade record did not return a session"); + return recorded.winner; +} + +/** Collects whatever a preHandler writes back, which is all these tests need from a reply. */ +function replyStub(): FastifyReply { + const written: string[] = []; + const reply = { + getHeader: () => written, + header: (_name: string, value: string[]) => { + written.splice(0, written.length, ...value); + return reply; + }, + }; + return reply as unknown as FastifyReply; +} + +/** Replays a response's cookies the way a browser would send them back. */ +function cookieHeader(response: Response): string { + return response.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); +} + /** Writes the exact row shape the pre-migration identity resolver produced. */ async function seedLegacyAccount(subject: string, email: string, displayName: string): Promise { const [user] = await client.database.insert(users).values({ email, displayName }).returning({ id: users.id }); @@ -199,6 +274,7 @@ describe("Better Auth over the existing Account tables", () => { const request = { headers: { cookie: "", authorization: `Bearer ${session.token}` } } as unknown as FastifyRequest; const authService = new AuthService(client.database, { issuePairForUser: async () => ({ accessToken: "", refreshToken: "", expiresIn: 0 }), + rotate: async () => ({ accessToken: "", refreshToken: "", expiresIn: 0 }), verifyAccess: async () => ({ expiresAt: new Date(), userId }), verifyRefresh: async () => ({ expiresAt: new Date(), userId }), }); @@ -213,6 +289,270 @@ describe("Better Auth over the existing Account tables", () => { }); }); + it("hands the CLI a revocable session through the connect-code contract it already speaks", async () => { + /* + * The response keeps its four fields, so a CLI built before the cutover stores and presents this unchanged. What + * changed is what the token is: a row the server can revoke, rather than a signature it can only wait out. + */ + const bootstrap = await bootstrapInitialAdmin(client.database, { + displayName: "Admin", + email: "admin@example.com", + workspaceDisplayName: "Example", + workspaceName: "example", + }); + const auth = createAuth(); + const authService = bridgedAuthService(auth); + + const exchanged = await authService.exchangeConnectCode(bootstrap.connectCode); + + expect(exchanged.tokenType).toBe("Bearer"); + expect(exchanged.expiresIn).toBeGreaterThan(0); + // One credential, not a pair: a session is revocable, so there is nothing for a second token to protect against. + expect(exchanged.refreshToken).toBe(exchanged.accessToken); + + const persisted = await client.database + .select() + .from(authSessions) + .where(eq(authSessions.userId, bootstrap.userId)); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.token).toBe(exchanged.accessToken); + + // The same token authenticates as a bearer credential, which is how the CLI already sends it. + const resolved = await auth.api.getSession({ + headers: new Headers({ authorization: `Bearer ${exchanged.accessToken}` }), + }); + expect(resolved?.user.id).toBe(bootstrap.userId); + + // Revocation is immediate, which the stateless pair could never offer. + await (await auth.$context).internalAdapter.deleteSession(exchanged.accessToken); + await expect(authService.getAuthenticatedUser(exchanged.accessToken)).rejects.toMatchObject({ + code: "AUTH_INVALID_TOKEN", + }); + }); + + it("gives a CLI credential the lifetime the refresh token used to carry", async () => { + /* + * One credential replaces a pair, so this lifetime has to carry what the refresh token's did: how long a CLI may + * go unused and still be signed in. Better Auth's own default is seven days, which would have shortened that from + * thirty without anyone choosing it, and left a CLI idle for longer unable to refresh at all. + */ + const bootstrap = await bootstrapInitialAdmin(client.database, { + displayName: "Admin", + email: "admin@example.com", + workspaceDisplayName: "Example", + workspaceName: "example", + }); + const before = Date.now(); + + const exchanged = await bridgedAuthService(createAuth()).exchangeConnectCode(bootstrap.connectCode); + + expect(exchanged.expiresIn).toBeGreaterThan(SESSION_TTL_SECONDS - 60); + expect(exchanged.expiresIn).toBeLessThanOrEqual(SESSION_TTL_SECONDS); + const [session] = await client.database.select().from(authSessions); + const lifetimeMs = (session?.expiresAt.getTime() ?? 0) - before; + expect(lifetimeMs).toBeGreaterThan((SESSION_TTL_SECONDS - 60) * 1000); + expect(lifetimeMs).toBeLessThanOrEqual((SESSION_TTL_SECONDS + 60) * 1000); + }); + + it("withdraws the CLI credential a refresh replaces, leaving one live session", async () => { + /* + * Access and refresh carry the same token, so a refresh replaces the only credential the CLI has. Issuing without + * withdrawing would leave the presented one valid until its own expiry: revoking what the CLI currently holds + * would not lock out a copy taken before its last refresh, and every refresh would leave another live row behind. + */ + const bootstrap = await bootstrapInitialAdmin(client.database, { + displayName: "Admin", + email: "admin@example.com", + workspaceDisplayName: "Example", + workspaceName: "example", + }); + const authService = bridgedAuthService(createAuth()); + const initial = await authService.exchangeConnectCode(bootstrap.connectCode); + + const renewed = await authService.refresh(initial.refreshToken); + + expect(renewed.accessToken).not.toBe(initial.accessToken); + const live = await client.database.select().from(authSessions).where(eq(authSessions.userId, bootstrap.userId)); + expect(live.map(({ token }) => token)).toEqual([renewed.accessToken]); + // Started one at a time: building both promises up front leaves the second rejecting while nothing is awaiting it. + for (const present of [ + () => authService.getAuthenticatedUser(initial.accessToken), + () => authService.refresh(initial.refreshToken), + ]) { + await expect(present()).rejects.toMatchObject({ code: "AUTH_INVALID_TOKEN" }); + } + await expect(authService.getAuthenticatedUser(renewed.accessToken)).resolves.toMatchObject({ + me: { user: { id: bootstrap.userId } }, + }); + }); + + it("lets one of two concurrent refreshes win, and never resurrects a revoked credential", async () => { + /* + * Verifying and then replacing decides both of these on stale information: two refreshes that verify the same + * token would each go on to mint a session, and a revocation landing between verification and replacement would be + * undone by the replacement. The withdrawal is the gate instead, so exactly one caller can proceed. + */ + const bootstrap = await bootstrapInitialAdmin(client.database, { + displayName: "Admin", + email: "admin@example.com", + workspaceDisplayName: "Example", + workspaceName: "example", + }); + const authService = bridgedAuthService(createAuth()); + const initial = await authService.exchangeConnectCode(bootstrap.connectCode); + + const raced = await Promise.allSettled([ + authService.refresh(initial.refreshToken), + authService.refresh(initial.refreshToken), + ]); + + const won = raced.filter((outcome) => outcome.status === "fulfilled"); + expect(won).toHaveLength(1); + expect(raced.filter((outcome) => outcome.status === "rejected")[0]).toMatchObject({ + reason: { code: "AUTH_INVALID_TOKEN" }, + }); + const live = await client.database.select().from(authSessions).where(eq(authSessions.userId, bootstrap.userId)); + expect(live).toHaveLength(1); + + // A refresh that starts against a credential something else has already revoked must not hand back access. + const survivor = won[0] as PromiseFulfilledResult<{ refreshToken: string }>; + await client.database.delete(authSessions).where(eq(authSessions.token, survivor.value.refreshToken)); + await expect(authService.refresh(survivor.value.refreshToken)).rejects.toMatchObject({ + code: "AUTH_INVALID_TOKEN", + }); + expect(await client.database.select().from(authSessions)).toHaveLength(0); + }); + + it("upgrades a credential the previous revision issued the first time it is refreshed", async () => { + /* + * A CLI that has not reached the server since the cutover still holds a signed pair. Verification falls back to the + * legacy signature, and because issuance only ever produces a session, refreshing is what moves it across. + */ + const bootstrap = await bootstrapInitialAdmin(client.database, { + displayName: "Admin", + email: "admin@example.com", + workspaceDisplayName: "Example", + workspaceName: "example", + }); + const auth = createAuth(); + const legacy = new AuthTokenService(LEGACY_SECRET, 900, 3600); + const legacyPair = await legacy.issuePairForUser(bootstrap.userId); + const authService = bridgedAuthService(auth); + + const refreshed = await authService.refresh(legacyPair.refreshToken); + + expect(refreshed.accessToken).not.toBe(legacyPair.accessToken); + const persisted = await client.database + .select() + .from(authSessions) + .where(eq(authSessions.userId, bootstrap.userId)); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.token).toBe(refreshed.accessToken); + + // The legacy access token still authenticates until it expires, so the rollout signs nobody out. + await expect(authService.getAuthenticatedUser(legacyPair.accessToken)).resolves.toMatchObject({ + me: { user: { id: bootstrap.userId } }, + }); + }); + + it("does not let a junk bearer header turn a cookie request into a CLI one", async () => { + /* + * Better Auth reads the header and the cookie, and on an invalid bearer it answers from the cookie. Deciding the + * transport from the header merely being present would therefore let a caller holding only the HttpOnly session + * cookie attach any `Authorization` value and be treated as the CLI: no origin check, no double-submit token, and + * mutations allowed. The transport is chosen before anything authenticates, so a presented bearer stands alone. + */ + await seedLegacyAccount("google-subject-transport", "transport@example.com", "Transport Account"); + const developer = new DevBrowserAuthService(client.database, "transport@example.com"); + const auth = createAuth(() => developer.resolveUserId()); + // Signed by Better Auth rather than assembled here, so this is the cookie a real browser would present. + const signedIn = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/dev/sign-in`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ); + expect(signedIn.status).toBe(200); + const cookie = cookieHeader(signedIn); + const preHandler = createUserAuthPreHandler(bridgedAuthService(auth), { + betterAuth: auth, + publicOrigin: PUBLIC_URL, + secureCookies: false, + sessionTtlSeconds: SESSION_TTL_SECONDS, + }); + const mutation = (headers: Record) => + preHandler({ headers, method: "POST" } as unknown as FastifyRequest, replyStub()); + + // The cookie is genuinely good: without the header it reaches the browser path and is refused only for the token. + await expect(mutation({ cookie, origin: PUBLIC_URL })).rejects.toMatchObject({ statusCode: 403 }); + + /* + * With a junk bearer alongside it, the request must be rejected as a bearer credential rather than falling through + * to that same cookie — the fallback is what would skip the origin and double-submit checks entirely. + * + * The token has to carry a `.` and a bad signature. Better Auth's bearer plugin signs a dotless token and installs + * it as the session cookie, which overwrites the real one and then fails on its own; only a token it reads as + * signed-but-invalid is dropped, leaving the genuine cookie to answer for the request. That is the shape an + * attacker would send, so it is the shape this asserts on. + */ + await expect(mutation({ authorization: "Bearer forged.signature", cookie })).rejects.toMatchObject({ + code: "AUTH_INVALID_TOKEN", + statusCode: 401, + }); + }); + + it("forwards the renewed session cookie, so an active browser is not signed out on the original schedule", async () => { + /* + * Better Auth extends a session as it is used and reports the replacement cookie in response headers. A + * result-only `getSession` throws those away, and the failure is silent: the row keeps moving while the browser + * keeps the cookie it was first given, so an active user is signed out at the original expiry and the renewed row + * is left behind. Nothing about the initial TTL catches that — only the header bridge does. + */ + await seedLegacyAccount("google-subject-renewal", "renewal@example.com", "Renewal Account"); + const developer = new DevBrowserAuthService(client.database, "renewal@example.com"); + const auth = createAuth(() => developer.resolveUserId()); + const signedIn = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/dev/sign-in`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ); + const cookie = cookieHeader(signedIn); + + /* + * Aged past `updateAge` by moving the expiry back, which is what Better Auth actually reads: + * `expiresAt - expiresIn + updateAge <= now` is its condition for refreshing. + */ + const aged = new Date(Date.now() + (SESSION_TTL_SECONDS - 2 * 24 * 60 * 60) * 1000); + await client.database.update(authSessions).set({ expiresAt: aged }); + + const reply = replyStub(); + await createUserAuthPreHandler(bridgedAuthService(auth), { + betterAuth: auth, + publicOrigin: PUBLIC_URL, + secureCookies: false, + sessionTtlSeconds: SESSION_TTL_SECONDS, + })({ headers: { cookie }, method: "GET" } as unknown as FastifyRequest, reply); + + // The row moved forward, which is the half that used to happen silently on its own. + const [renewed] = await client.database.select().from(authSessions); + expect(renewed?.expiresAt.getTime()).toBeGreaterThan(aged.getTime()); + + // And the browser was told: the replacement cookie has to reach the reply, or it keeps the one that expires first. + const written = ([] as string[]).concat((reply.getHeader("set-cookie") ?? []) as string[]); + const sessionCookieName = (await auth.$context).authCookies.sessionToken.name; + const forwarded = written.find((value) => value.startsWith(`${sessionCookieName}=`)); + expect(forwarded, "the renewed session cookie was not forwarded to the browser").toBeDefined(); + + // What it forwarded is a working credential, not just any cookie. + const replacement = forwarded?.split(";", 1)[0] ?? ""; + await expect(auth.api.getSession({ headers: new Headers({ cookie: replacement }) })).resolves.toMatchObject({ + user: { email: "renewal@example.com" }, + }); + }); + it("never persists provider credentials on the identity row", async () => { const userId = await seedLegacyAccount("google-subject-tokens", "tokens@example.com", "Token Account"); const auth = createAuth(); @@ -252,4 +592,180 @@ describe("Better Auth over the existing Account tables", () => { const [stored] = await client.database.select().from(users).where(eq(users.id, created.id)); expect(stored?.email).toBe("mixed.casing@example.com"); }); + + it("signs the configured development Account in with a session that sign-out revokes", async () => { + const configured = await seedLegacyAccount("google-subject-dev", "dev@example.com", "Dev Account"); + const other = await seedLegacyAccount("google-subject-other", "other@example.com", "Other Account"); + // Mixed casing on purpose: the resolver matches on the normalized address, as the configured value is unnormalized. + const developer = new DevBrowserAuthService(client.database, "DEV@Example.com"); + const auth = createAuth(() => developer.resolveUserId()); + + const signIn = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/dev/sign-in`, { + method: "POST", + headers: { "content-type": "application/json" }, + // The endpoint takes no input, so a caller naming another Account still gets the configured one. + body: JSON.stringify({ userId: other }), + }), + ); + expect(signIn.status).toBe(200); + + const issued = await client.database.select().from(authSessions); + expect(issued).toHaveLength(1); + expect(issued[0]?.userId).toBe(configured); + + /* + * The credential has to be the one Better Auth itself understands. A session token written into OpenTag's own + * cookie would still authenticate through the legacy fallback, so the sign-in would look correct — but `getSession` + * would not see it, and sign-out would have nothing to revoke. + */ + const cookie = cookieHeader(signIn); + await expect(auth.api.getSession({ headers: new Headers({ cookie }) })).resolves.toMatchObject({ + user: { email: "dev@example.com", id: configured }, + }); + + const signOut = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/sign-out`, { + method: "POST", + headers: { cookie, "content-type": "application/json" }, + body: "{}", + }), + ); + expect(signOut.status).toBe(200); + expect(await client.database.select().from(authSessions)).toHaveLength(0); + await expect(auth.api.getSession({ headers: new Headers({ cookie }) })).resolves.toBeNull(); + }); + + it("upgrades a browser the previous revision signed in, without asking it to sign in again", async () => { + const bootstrap = await bootstrapInitialAdmin(client.database, { + displayName: "Browser", + email: "browser@example.com", + workspaceDisplayName: "Example", + workspaceName: "example", + }); + const legacy = new AuthTokenService(LEGACY_SECRET, 900, 3600); + const legacyPair = await legacy.issuePairForUser(bootstrap.userId); + const authService = bridgedAuthService(createAuth()); + const auth = createAuth(undefined, async (refreshToken) => { + const identity = await legacy.verifyRefresh(refreshToken); + return { expiresAt: identity.expiresAt, userId: (await authService.getActiveUserById(identity.userId)).user.id }; + }); + + const upgrade = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/legacy/upgrade`, { + method: "POST", + // The origin a browser actually sends, so a trusted-origin rejection would surface here rather than in staging. + headers: { "content-type": "application/json", origin: PUBLIC_URL }, + body: JSON.stringify({ refreshToken: legacyPair.refreshToken }), + }), + ); + + expect(upgrade.status).toBe(200); + const cookie = cookieHeader(upgrade); + await expect(auth.api.getSession({ headers: new Headers({ cookie }) })).resolves.toMatchObject({ + user: { id: bootstrap.userId }, + }); + // The same Account, not a second one: the upgrade must not read as a new person signing in. + expect(await client.database.select().from(users)).toHaveLength(1); + }); + + it("converges a replayed or raced upgrade on one session that sign-out ends", async () => { + /* + * A stateless refresh token has nothing to consume, so nothing stops it being presented twice — a replay, or two + * requests from the same browser that met a 401 together. Each exchange that minted its own session would leave + * every row but the last invisible to the browser that created it, and therefore alive after the sign-out meant to + * end it. That is the orphan-session failure this endpoint exists to remove, reintroduced by concurrency. + */ + const bootstrap = await bootstrapInitialAdmin(client.database, { + displayName: "Browser", + email: "browser@example.com", + workspaceDisplayName: "Example", + workspaceName: "example", + }); + const legacy = new AuthTokenService(LEGACY_SECRET, 900, 3600); + const legacyPair = await legacy.issuePairForUser(bootstrap.userId); + const authService = bridgedAuthService(createAuth()); + const auth = createAuth(undefined, async (refreshToken) => { + const identity = await legacy.verifyRefresh(refreshToken); + return { expiresAt: identity.expiresAt, userId: (await authService.getActiveUserById(identity.userId)).user.id }; + }); + const upgrade = () => + auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/legacy/upgrade`, { + method: "POST", + headers: { "content-type": "application/json", origin: PUBLIC_URL }, + body: JSON.stringify({ refreshToken: legacyPair.refreshToken }), + }), + ); + + const [first, second] = await Promise.all([upgrade(), upgrade()]); + const replay = await upgrade(); + + for (const response of [first, second, replay]) expect(response.status).toBe(200); + const sessions = await client.database.select().from(authSessions); + expect(sessions).toHaveLength(1); + /* + * Every exchange hands back the same session. The browser keeps whichever `Set-Cookie` arrives last, which is not + * necessarily the exchange that won the race — converging on one token is what stops it from being left holding a + * cookie for a row that was deleted. + */ + for (const response of [first, second, replay]) { + await expect( + auth.api.getSession({ headers: new Headers({ cookie: cookieHeader(response) }) }), + ).resolves.toMatchObject({ session: { token: sessions[0]?.token }, user: { id: bootstrap.userId } }); + } + + const signOut = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/sign-out`, { + method: "POST", + headers: { cookie: cookieHeader(first), "content-type": "application/json" }, + body: "{}", + }), + ); + + expect(signOut.status).toBe(200); + expect(await client.database.select().from(authSessions)).toHaveLength(0); + }); + + it("refuses to upgrade a refresh credential that does not verify", async () => { + const legacy = new AuthTokenService(LEGACY_SECRET, 900, 3600); + const authService = bridgedAuthService(createAuth()); + const auth = createAuth(undefined, async (refreshToken) => { + const identity = await legacy.verifyRefresh(refreshToken); + return { expiresAt: identity.expiresAt, userId: (await authService.getActiveUserById(identity.userId)).user.id }; + }); + + const forged = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/legacy/upgrade`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refreshToken: `${randomUUID()}.${randomUUID()}.${randomUUID()}` }), + }), + ); + + // Nothing else guards this endpoint, so a token that does not verify must produce no session at all. + expect(forged.status).toBe(401); + expect(await client.database.select().from(authSessions)).toHaveLength(0); + expect(forged.headers.getSetCookie()).toEqual([]); + }); + + it("refuses development sign-in when the configured Account is ambiguous", async () => { + await seedLegacyAccount("google-subject-twin-a", "twin@example.com", "Twin One"); + await seedLegacyAccount("google-subject-twin-b", "TWIN@example.com", "Twin Two"); + const developer = new DevBrowserAuthService(client.database, "twin@example.com"); + const auth = createAuth(() => developer.resolveUserId()); + + const response = await auth.handler( + new Request(`${PUBLIC_URL}/api/v1/auth/dev/sign-in`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ); + + // Reported as the answerable failure it is, so a misconfigured email is not logged as an internal server error. + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ code: "AUTH_DEV_USER_UNAVAILABLE" }); + expect(await client.database.select().from(authSessions)).toHaveLength(0); + }); }); diff --git a/packages/server/src/__tests__/server-startup.test.ts b/packages/server/src/__tests__/server-startup.test.ts index 611c95e0..931ac47b 100644 --- a/packages/server/src/__tests__/server-startup.test.ts +++ b/packages/server/src/__tests__/server-startup.test.ts @@ -103,7 +103,12 @@ vi.mock("../runtime/runtime-domain-owner.js", () => ({ vi.mock("../services/agents/index.js", () => ({ AgentService: class {}, AgentServiceError: class extends Error {} })); vi.mock("../services/auth/index.js", () => ({ AuthIdentityService: class {}, - AuthService: class {}, + AuthService: class { + constructor( + _database: unknown, + readonly tokens: unknown, + ) {} + }, AuthServiceError: class extends Error {}, AuthTokenService: class {}, ConnectCodeService: class {}, @@ -197,6 +202,7 @@ vi.mock("../services/workspaces/index.js", () => ({ vi.mock("../web-app.js", () => ({ defaultWebAppRoot: "/mock-web" })); import { startServer } from "../index.js"; +import * as authModule from "../services/auth/index.js"; const originalSecrets = { database: process.env.OPENTAG_DATABASE_URL, @@ -343,14 +349,21 @@ describe("Server startup", () => { ]); const appOptions = state.appOptions as { - browserAuth: { dev: unknown; google: unknown; secureCookies: boolean }; + browserAuth: { devSignIn: unknown; google: unknown; secureCookies: boolean }; slackEvents: { createAdapter(binding: unknown): unknown }; }; expect(appOptions.browserAuth).toMatchObject({ secureCookies: true }); - expect(appOptions.browserAuth.dev).toBeDefined(); + expect(appOptions.browserAuth.devSignIn).toBe(true); expect(appOptions.browserAuth.google).toBeDefined(); expect(state.devAuthArgs).toEqual(expect.arrayContaining(["dev@example.com"])); expect(state.googleOptions).toMatchObject({ publicUrl: state.config.publicUrl }); + /* + * The retained legacy callback only ever completes a flow that started before this revision deployed, and it + * writes its result into the legacy cookies. Handing it the bridged issuer would put a session token there: + * authenticated through the fallback, invisible to `getSession`, and therefore beyond what sign-out can revoke. + */ + const googleIssuer = (state.googleOptions as { tokenIssuer: { tokens: unknown } }).tokenIssuer; + expect(googleIssuer.tokens).toBeInstanceOf(authModule.AuthTokenService); const slackBinding = { botAccessToken: "xoxb-current", diff --git a/packages/server/src/api/browser-auth.ts b/packages/server/src/api/browser-auth.ts index 0a4f7e36..62d8123e 100644 --- a/packages/server/src/api/browser-auth.ts +++ b/packages/server/src/api/browser-auth.ts @@ -1,22 +1,25 @@ import { isIP } from "node:net"; import { AuthProvidersResponseSchema, HTTP_PATHS } from "@opentag/shared"; import { fromNodeHeaders } from "better-auth/node"; -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyRequest } from "fastify"; import { z } from "zod"; import type { OpenTagBetterAuth } from "../auth/better-auth.js"; -import { callBetterAuth, copyBetterAuthCookies } from "../auth/fastify-handler.js"; +import { betterAuthFailure, callBetterAuth, copyBetterAuthCookies } from "../auth/fastify-handler.js"; +import { DEV_SIGN_IN_PATH, LEGACY_UPGRADE_PATH } from "../auth/internal-sign-in.js"; import { BROWSER_COOKIE_NAMES, clearBrowserSessionCookies, + clearLegacyCredentialCookies, clearOAuthContextCookie, parseCookies, requireBrowserMutationSecurity, requireRefreshCookie, + setBrowserCsrfCookie, setBrowserSessionCookies, setOAuthContextCookie, } from "../services/auth/browser-cookies.js"; -import { AuthServiceError } from "../services/auth/errors.js"; -import type { DevBrowserAuthService, GoogleBrowserAuthService, UserAuthService } from "../services/auth/index.js"; +import { AuthServiceError, invalidCredential } from "../services/auth/errors.js"; +import type { GoogleBrowserAuthService, UserAuthService } from "../services/auth/index.js"; import { validateOAuthNext } from "../services/auth/index.js"; import { parseRequest } from "./request-validation.js"; @@ -41,11 +44,20 @@ const CallbackQuerySchema = z export interface BrowserAuthRoutesOptions { /** Present once Better Auth owns the browser session; the legacy paths stay for credentials it did not issue. */ betterAuth?: { instance: OpenTagBetterAuth; publicUrl: string }; - dev?: DevBrowserAuthService; + /** + * Whether the loopback-only development sign-in is configured. + * + * Which Account it signs in is fixed inside the Better Auth instance, so this route decides only whether a request + * may ask for it at all. + */ + devSignIn?: boolean; google?: GoogleBrowserAuthService; publicOrigin: string; + /** Lifetime of the credentials the previous revision issued, and of the cookies that still carry them. */ refreshTokenTtlSeconds: number; secureCookies: boolean; + /** Lifetime of a Better Auth session, and therefore of the double-submit token that has to outlast it. */ + sessionTtlSeconds: number; } function isLoopbackAddress(value: string): boolean { @@ -89,8 +101,12 @@ export function registerBrowserAuthRoutes( ): void { const limiter = new RouteRateLimiter(); + /** Development sign-in is offered only to a loopback client reaching a loopback host, on a server configured for it. */ + const devSignInAvailable = (request: FastifyRequest): boolean => + Boolean(options.devSignIn) && isLoopbackAddress(request.ip) && isLoopbackAddress(request.hostname); + app.get(HTTP_PATHS.authProviders, async (request, reply) => { - const devAvailable = Boolean(options.dev) && isLoopbackAddress(request.ip) && isLoopbackAddress(request.hostname); + const devAvailable = devSignInAvailable(request); return reply.code(200).send( AuthProvidersResponseSchema.parse({ providers: [ @@ -111,7 +127,8 @@ export function registerBrowserAuthRoutes( app.get(HTTP_PATHS.authDevCallback, async (request, reply) => { limiter.check(`${request.ip}:dev`); - if (!options.dev || !isLoopbackAddress(request.ip) || !isLoopbackAddress(request.hostname)) { + const betterAuth = options.betterAuth; + if (!devSignInAvailable(request) || !betterAuth) { throw new AuthServiceError( "AUTH_PROVIDER_DISABLED", "deterministic", @@ -121,9 +138,29 @@ export function registerBrowserAuthRoutes( } const { next } = parseRequest(StartQuerySchema, request.query); const destination = validateOAuthNext(next); - const tokens = await options.dev.signIn(); - setBrowserSessionCookies(reply, tokens, { - refreshTtlSeconds: options.refreshTokenTtlSeconds, + /* + * Better Auth mints the session so a development sign-in is the same revocable credential a Google sign-in is: + * visible to `getSession`, and therefore actually ended by sign-out. Writing a session token into OpenTag's own + * cookies instead would hide it from both. + */ + const response = await callBetterAuth(betterAuth.instance, betterAuth.publicUrl, request, { + method: "POST", + path: DEV_SIGN_IN_PATH, + body: {}, + }); + if (!response.ok) { + // Better Auth's error body is not OpenTag's envelope, so the failure is restated rather than forwarded. + throw new AuthServiceError( + "AUTH_DEV_USER_UNAVAILABLE", + "deterministic", + "The configured development sign-in user is unavailable or ambiguous", + 503, + ); + } + copyBetterAuthCookies(reply, response); + // The session cookie alone cannot write: every browser mutation also carries OpenTag's double-submit token. + setBrowserCsrfCookie(reply, { + maxAgeSeconds: options.sessionTtlSeconds, secure: options.secureCookies, }); return reply.redirect(destination, 302); @@ -196,7 +233,36 @@ export function registerBrowserAuthRoutes( app.post(HTTP_PATHS.authBrowserRefresh, async (request, reply) => { requireBrowserMutationSecurity(request, options.publicOrigin); - const tokens = await authService.refresh(requireRefreshCookie(request)); + const refreshToken = requireRefreshCookie(request); + const betterAuth = options.betterAuth; + if (betterAuth) { + /* + * Only a browser that has not signed in since the cutover still holds this cookie, so refreshing it is that + * browser's one chance to move across. It is spent on a Better Auth session rather than another legacy pair: + * anything else leaves a session sign-out cannot revoke, or a credential that stops working when the legacy + * secret is retired. + */ + const response = await callBetterAuth(betterAuth.instance, betterAuth.publicUrl, request, { + method: "POST", + path: LEGACY_UPGRADE_PATH, + body: { refreshToken }, + }); + if (!response.ok) { + throw await betterAuthFailure( + response, + invalidCredential("AUTH_INVALID_TOKEN", "The refresh token is invalid"), + ); + } + copyBetterAuthCookies(reply, response); + setBrowserCsrfCookie(reply, { + maxAgeSeconds: options.sessionTtlSeconds, + secure: options.secureCookies, + }); + // Retired only now that the replacement is on the reply, so a failure above leaves the browser able to retry. + clearLegacyCredentialCookies(reply, options.secureCookies); + return reply.code(204).send(); + } + const tokens = await authService.refresh(refreshToken); setBrowserSessionCookies(reply, tokens, { refreshTtlSeconds: options.refreshTokenTtlSeconds, secure: options.secureCookies, diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 1f31c884..61d4ee8a 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -193,12 +193,18 @@ export function createApp(options: CreateAppOptions = {}) { const authOptions = { ...(options.betterAuth ? { betterAuth: options.betterAuth.instance } : {}), ...(publicOrigin ? { publicOrigin } : {}), + ...(options.browserAuth + ? { + secureCookies: options.browserAuth.secureCookies, + sessionTtlSeconds: options.browserAuth.sessionTtlSeconds, + } + : {}), }; if (options.betterAuth) { registerBetterAuthRoutes(app, options.betterAuth.instance, { publicUrl: options.betterAuth.publicUrl, secureCookies: options.browserAuth?.secureCookies ?? true, - sessionTtlSeconds: options.browserAuth?.refreshTokenTtlSeconds ?? 60 * 60 * 24 * 7, + sessionTtlSeconds: options.browserAuth?.sessionTtlSeconds ?? 60 * 60 * 24 * 30, }); } registerAuthRoutes(app, authService); diff --git a/packages/server/src/auth/better-auth.ts b/packages/server/src/auth/better-auth.ts index 5239efb4..43f23cc9 100644 --- a/packages/server/src/auth/better-auth.ts +++ b/packages/server/src/auth/better-auth.ts @@ -3,6 +3,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { bearer } from "better-auth/plugins/bearer"; import type { DatabaseClient } from "../db/client.js"; import { authIdentities, authSessions, authVerifications, users } from "../db/schema/index.js"; +import { devSignInPlugin, type LegacyUpgradeOptions, legacyUpgradePlugin } from "./internal-sign-in.js"; /** * Where the instance will be mounted, under the repository's `/api/v1` versioning convention rather than Better Auth's @@ -31,7 +32,27 @@ export interface BetterAuthConfig { publicUrl: string; secret: string; secureCookies: boolean; + /** + * How long an issued session lives, and therefore how long a client may be idle and still be signed in. + * + * Left to the library this would default to seven days and quietly shorten what a CLI was promised, so it is + * required rather than optional: a lifetime this visible should not be something a caller can forget to state. + */ + sessionTtlSeconds: number; + /** + * Resolves the single Account development sign-in may issue a session for. + * + * Supplied only when development sign-in is configured, so the endpoint does not exist on a server without it. + */ + devSignIn?: () => Promise; google?: { clientId: string; clientSecret: string }; + /** + * Verifies a refresh credential the previous revision issued and answers whose it is. + * + * Supplied while the compatibility window is open. It must reject anything it cannot verify: it is the only thing + * standing between the upgrade endpoint and an unauthenticated session. + */ + legacyUpgrade?: LegacyUpgradeOptions; } export type OpenTagBetterAuth = ReturnType; @@ -68,6 +89,12 @@ export function createBetterAuth(database: DatabaseClient, config: BetterAuthCon }, useSecureCookies: config.secureCookies, }, + /* + * One credential replaces a pair, so this single lifetime has to carry what the refresh token's did: how long a + * client may go unused and still be signed in. Inheriting the library's seven days would have shortened that from + * thirty without anyone choosing it, and left an idle CLI unable to refresh. + */ + session: { expiresIn: config.sessionTtlSeconds }, user: { fields: { name: "displayName" } }, account: { fields: { accountId: "subject", providerId: "provider" }, @@ -113,8 +140,12 @@ export function createBetterAuth(database: DatabaseClient, config: BetterAuthCon }, } : {}), - // The CLI authenticates with `Authorization: Bearer `; the browser keeps using cookies. - plugins: [bearer()], + plugins: [ + // The CLI authenticates with `Authorization: Bearer `; the browser keeps using cookies. + bearer(), + ...(config.devSignIn ? [devSignInPlugin(config.devSignIn)] : []), + ...(config.legacyUpgrade ? [legacyUpgradePlugin(config.legacyUpgrade)] : []), + ], }); } diff --git a/packages/server/src/auth/fastify-handler.ts b/packages/server/src/auth/fastify-handler.ts index 498c50d1..29944fd4 100644 --- a/packages/server/src/auth/fastify-handler.ts +++ b/packages/server/src/auth/fastify-handler.ts @@ -1,6 +1,8 @@ +import { ErrorCodeSchema } from "@opentag/shared"; import { fromNodeHeaders } from "better-auth/node"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { appendSetCookies, setBrowserCsrfCookie } from "../services/auth/browser-cookies.js"; +import { AuthServiceError } from "../services/auth/errors.js"; import { BETTER_AUTH_BASE_PATH, type OpenTagBetterAuth } from "./better-auth.js"; /** @@ -119,6 +121,20 @@ export async function sendBetterAuthResponse(reply: FastifyReply, response: Resp return reply.send(Buffer.from(await response.arrayBuffer())); } +/** + * Restates a failed Better Auth call in OpenTag's error envelope. + * + * Better Auth replies in its own shape, which the client would read as an unrecognized failure and flatten. The status + * and code an OpenTag endpoint reported are carried across so a rejected credential and a suspended Account stay + * distinguishable; anything else becomes the caller's fallback rather than being guessed at. + */ +export async function betterAuthFailure(response: Response, fallback: AuthServiceError): Promise { + const body = (await response.json().catch(() => undefined)) as { code?: unknown; message?: unknown } | undefined; + const code = ErrorCodeSchema.safeParse(body?.code); + if (!code.success || typeof body?.message !== "string") return fallback; + return new AuthServiceError(code.data, fallback.category, body.message, response.status); +} + /** Whether a Better Auth response handed the browser a session cookie. */ async function isSessionEstablished(auth: OpenTagBetterAuth, response: Response): Promise { const sessionCookieName = (await auth.$context).authCookies.sessionToken.name; diff --git a/packages/server/src/auth/internal-sign-in.ts b/packages/server/src/auth/internal-sign-in.ts new file mode 100644 index 00000000..2e5c1f47 --- /dev/null +++ b/packages/server/src/auth/internal-sign-in.ts @@ -0,0 +1,177 @@ +import type { BetterAuthPlugin } from "better-auth"; +import { APIError, createAuthEndpoint } from "better-auth/api"; +import { setSessionCookie } from "better-auth/cookies"; +import { z } from "zod"; +import { AuthServiceError } from "../services/auth/errors.js"; +import { hashSecret } from "../services/auth/security.js"; + +/** + * Paths below the Better Auth base path, both deliberately absent from the published route allowlist. + * + * Nothing routes to them from outside. Their only callers are OpenTag routes that have already decided the request may + * have a session, and reach them server-side. + */ +export const DEV_SIGN_IN_PATH = "/dev/sign-in"; +export const LEGACY_UPGRADE_PATH = "/legacy/upgrade"; + +/** Whatever an endpoint here can be told; the Account it acts on never comes from the caller. */ +const LegacyUpgradeBodySchema = z.object({ refreshToken: z.string().min(1).max(4096) }); + +/** + * Signs the one configured development Account in. + * + * It takes no input at all. The Account comes from `resolveUserId`, fixed at construction from configuration, so the + * endpoint cannot be aimed at a different Account even by a caller that reaches it. Whether a request may sign in this + * way is decided before this by the route's loopback fences; the plugin itself is only registered when development + * sign-in is configured, which `parseServerConfig` already restricts to a loopback `OPENTAG_ENV=dev` server. + */ +export function devSignInPlugin(resolveUserId: () => Promise): BetterAuthPlugin { + return { + id: "opentag-dev-sign-in", + endpoints: { + opentagDevSignIn: createAuthEndpoint(DEV_SIGN_IN_PATH, { method: "POST" }, async (ctx) => { + const userId = await resolve(resolveUserId()); + await establishSession(ctx, userId); + return ctx.json({ userId }); + }), + }, + }; +} + +/** What a presented legacy credential resolves to, once it has been verified. */ +export interface LegacyCredential { + /** When the presented credential itself expires, which bounds how long its exchange has to stay recorded. */ + expiresAt: Date; + userId: string; +} + +/** + * Exchanges a credential the previous revision issued for the session that replaces it. + * + * This is not a way to sign in without one: the caller must present a refresh token that still verifies, and + * `resolveCredential` is what decides whether it does. It exists so a browser that has not signed in since the cutover + * moves across on its next refresh rather than being asked to sign in again. + * + * One legacy credential converges on one session, however many times it is presented. A stateless refresh token has + * nothing to consume, so the exchange is recorded against the token itself in a single statement that returns the + * winning session — the first writer's. A later caller withdraws the session it had just created and hands back the + * winner's, so every response carries the same token and it does not matter which `Set-Cookie` the browser applies + * last. Without that, a replay or two tabs racing a `401` would each leave a live row invisible to the browser holding + * the cookie, surviving the sign-out meant to end it. + * + * The record is the gate, so nothing here takes a lock. An advisory lock would be held by one connection while every + * waiter held another, and the holder still needs a connection of its own to do the work: a pool of ten stalls on ten + * concurrent exchanges of the same credential. + */ +export function legacyUpgradePlugin(options: LegacyUpgradeOptions): BetterAuthPlugin { + return { + id: "opentag-legacy-upgrade", + endpoints: { + opentagLegacyUpgrade: createAuthEndpoint( + LEGACY_UPGRADE_PATH, + { body: LegacyUpgradeBodySchema, method: "POST" }, + async (ctx) => { + const refreshToken = ctx.body.refreshToken; + const credential = await resolve(options.resolveCredential(refreshToken)); + const user = await requireUser(ctx, credential.userId); + const session = await createSession(ctx, credential.userId); + + const winner = await options.recordExchange({ + expiresAt: credential.expiresAt, + sessionToken: session.token, + tokenHash: hashSecret(refreshToken), + }); + if (winner === session.token) { + await setSessionCookie(ctx, { session, user }); + return ctx.json({ userId: credential.userId }); + } + + /* + * Another exchange of this credential got there first. Withdraw the session just created — it was handed to + * nobody — and hand back theirs, so the credential still corresponds to exactly one revocable row. + */ + await ctx.context.internalAdapter.deleteSession(session.token); + const existing = await ctx.context.internalAdapter.findSession(winner); + if (!existing) { + // The credential is spent and the session it produced is already gone; there is nothing to hand back. + throw new APIError("UNAUTHORIZED", { + code: "AUTH_INVALID_TOKEN", + message: "The refresh token has already been exchanged", + }); + } + await setSessionCookie(ctx, { session: existing.session, user }); + return ctx.json({ userId: credential.userId }); + }, + ), + }, + }; +} + +/** One exchange of a legacy credential. */ +export interface LegacyExchange { + expiresAt: Date; + sessionToken: string; + tokenHash: string; +} + +export interface LegacyUpgradeOptions { + /** + * Records this exchange and answers with the session token that won, which is this one only on a first exchange. + * + * It has to decide the winner in one statement. Better Auth's `reserveVerificationValue` looks like the primitive + * for exactly this and is not: its first-writer-wins comes from writing a derived primary key, and + * `auth_verifications.id` is a `uuid` column with a default, so the derived id does not survive the insert and every + * caller reserves successfully. + */ + recordExchange: (exchange: LegacyExchange) => Promise; + resolveCredential: (refreshToken: string) => Promise; +} + +/** The statuses OpenTag's authentication decisions produce, named as Better Auth's error constructor wants them. */ +const API_STATUSES = { + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 409: "CONFLICT", + 429: "TOO_MANY_REQUESTS", + 503: "SERVICE_UNAVAILABLE", +} as const; + +/** + * Reports an answerable failure as one. + * + * An `AuthServiceError` is a decision — a rejected credential, a misconfigured address — and carries the status and + * code the caller should see. Letting it escape would log it as an internal server error, burying it among real ones, + * and would flatten every decision to the same 500. Anything else is unexpected and propagates untouched. + */ +function resolve(resolved: Promise): Promise { + return resolved.catch((cause: unknown) => { + if (!(cause instanceof AuthServiceError)) throw cause; + const status = API_STATUSES[cause.statusCode as keyof typeof API_STATUSES] ?? "INTERNAL_SERVER_ERROR"; + throw new APIError(status, { code: cause.code, message: cause.message }); + }); +} + +type EndpointContext = Parameters[0]; + +/** Issues the session and the cookie Better Auth itself reads, so sign-out can end what this established. */ +async function establishSession(ctx: EndpointContext, userId: string): Promise { + const user = await requireUser(ctx, userId); + await setSessionCookie(ctx, { session: await createSession(ctx, userId), user }); +} + +async function requireUser(ctx: EndpointContext, userId: string) { + const user = await ctx.context.internalAdapter.findUserById(userId); + if (!user) { + throw new APIError("UNAUTHORIZED", { code: "AUTH_INVALID_TOKEN", message: "The Account no longer exists" }); + } + return user; +} + +/** Runs the same `session.create` hook every other sign-in does, so a suspended Account is refused here too. */ +async function createSession(ctx: EndpointContext, userId: string) { + const session = await ctx.context.internalAdapter.createSession(userId); + if (!session) { + throw new APIError("INTERNAL_SERVER_ERROR", { message: "A session could not be issued" }); + } + return session; +} diff --git a/packages/server/src/auth/session-tokens.ts b/packages/server/src/auth/session-tokens.ts new file mode 100644 index 00000000..4036534f --- /dev/null +++ b/packages/server/src/auth/session-tokens.ts @@ -0,0 +1,140 @@ +import { and, eq } from "drizzle-orm"; +import type { DatabaseClient } from "../db/client.js"; +import { authSessions } from "../db/schema/index.js"; +import { AuthServiceError, invalidCredential } from "../services/auth/errors.js"; +import type { AuthTokenIdentity, AuthTokenPair, AuthTokenProvider } from "../services/auth/tokens.js"; +import type { OpenTagBetterAuth } from "./better-auth.js"; + +/** + * Issues Better Auth sessions through the interface the stateless JWTs used. + * + * Every caller — connect-code exchange, refresh, and request authentication — already speaks {@link AuthTokenProvider}, + * so swapping the implementation moves the CLI onto revocable server-side sessions without changing any of them, and + * without changing the four-field response the CLI stores. `accessToken` and `refreshToken` carry the same session + * token because a session is not a pair: it is one credential the server can revoke, and re-presenting it is what + * extends it. + */ +export class BetterAuthSessionTokens implements AuthTokenProvider { + readonly #auth: OpenTagBetterAuth; + readonly #database: DatabaseClient; + readonly #now: () => Date; + + constructor(auth: OpenTagBetterAuth, database: DatabaseClient, options: { now?: () => Date } = {}) { + this.#auth = auth; + this.#database = database; + this.#now = options.now ?? (() => new Date()); + } + + async issuePairForUser(userId: string): Promise { + const context = await this.#auth.$context; + const session = await context.internalAdapter.createSession(userId); + if (!session) throw invalidCredential("AUTH_INVALID_TOKEN", "A session could not be issued"); + return { + accessToken: session.token, + refreshToken: session.token, + expiresIn: this.#secondsUntil(session.expiresAt), + }; + } + + /** + * Withdraws the presented session and issues its replacement, in that order. + * + * The delete is the gate, and it is what makes this safe to race: exactly one caller can remove a given row, so two + * refreshes of the same credential cannot both go on to mint a session, and a revocation that lands first means the + * delete finds nothing and no replacement is created. Verifying and then deleting would decide both of those on + * stale information — two live sessions from one credential, or access restored after it was revoked. + * + * Withdrawing first does mean a failure before the replacement exists signs the client out. That is the direction to + * fail in: the alternative keeps a credential alive that something already decided to end. + */ + async rotate(token: string, userId: string): Promise { + const [withdrawn] = await this.#database + .delete(authSessions) + .where(and(eq(authSessions.token, token), eq(authSessions.userId, userId))) + .returning({ userId: authSessions.userId }); + if (!withdrawn) throw invalidCredential("AUTH_INVALID_TOKEN", "The token is invalid"); + return this.issuePairForUser(withdrawn.userId); + } + + verifyAccess(token: string): Promise { + return this.#verify(token); + } + + verifyRefresh(token: string): Promise { + return this.#verify(token); + } + + async #verify(token: string): Promise { + const context = await this.#auth.$context; + const found = await context.internalAdapter.findSession(token); + if (!found || found.session.expiresAt.getTime() <= this.#now().getTime()) { + throw invalidCredential("AUTH_INVALID_TOKEN", "The token is invalid"); + } + return { expiresAt: found.session.expiresAt, userId: found.session.userId }; + } + + #secondsUntil(expiresAt: Date): number { + return Math.max(1, Math.floor((expiresAt.getTime() - this.#now().getTime()) / 1000)); + } +} + +/** + * Accepts credentials the previous revision issued while every new one is a Better Auth session. + * + * Verification tries the session first and falls back to the legacy signature, so a CLI that has not been near the + * server since the cutover keeps working. Issuance only ever produces a session — including on the refresh path, which + * is therefore what quietly upgrades a legacy credential the first time it is presented. + */ +export class BridgedSessionTokens implements AuthTokenProvider { + readonly #legacy: AuthTokenProvider; + readonly #sessions: AuthTokenProvider; + + constructor(sessions: AuthTokenProvider, legacy: AuthTokenProvider) { + this.#sessions = sessions; + this.#legacy = legacy; + } + + issuePairForUser(userId: string): Promise { + return this.#sessions.issuePairForUser(userId); + } + + /** + * Always produces a session, and withdraws the presented credential when the session store is the one holding it. + * + * A credential the session store rejects is only rotated into a new session once the legacy provider vouches for it. + * Issuing on any rejection would resurrect a session that was revoked between verification and withdrawal — the + * legacy check is what separates "this was never a session" from "this session is gone". + */ + async rotate(token: string, userId: string): Promise { + try { + return await this.#sessions.rotate(token, userId); + } catch (cause) { + if (!(cause instanceof AuthServiceError) || cause.code !== "AUTH_INVALID_TOKEN") throw cause; + await this.#legacy.verifyRefresh(token); + // Nothing to withdraw: a signature cannot be taken back, so it simply runs out on its own schedule. + return this.#sessions.issuePairForUser(userId); + } + } + + verifyAccess(token: string): Promise { + return this.#either((provider) => provider.verifyAccess(token)); + } + + verifyRefresh(token: string): Promise { + return this.#either((provider) => provider.verifyRefresh(token)); + } + + async #either(verify: (provider: AuthTokenProvider) => Promise): Promise { + try { + return await verify(this.#sessions); + } catch (cause) { + /* + * Only an explicit "this store does not know that token" means the credential might be a legacy one. A session + * store that is failing must not be reported as an invalid credential, and must not get a legacy credential + * admitted behind its back: an outage would silently widen what the server accepts. + */ + if (!(cause instanceof AuthServiceError) || cause.code !== "AUTH_INVALID_TOKEN") throw cause; + return verify(this.#legacy); + } + } +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 7f4d9798..07325323 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -113,6 +113,16 @@ const ServerEnvironmentSchema = z .int() .positive() .default(60 * 60 * 24 * 30), + /* + * Defaults to what the refresh token's lifetime was, because that is the number this replaces: how long a client + * may be idle and still be signed in. It is defaulted so no deployment has to be configured before the revision + * that reads it. + */ + OPENTAG_SESSION_TTL_SECONDS: z.coerce + .number() + .int() + .positive() + .default(60 * 60 * 24 * 30), OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID: StagingOnboardingAccountIdSchema, }) .strict() @@ -241,7 +251,10 @@ export interface ServerConfig { }; port: number; publicUrl: string; + /** Lifetime of the credentials the previous revision issued; they are only verified now, never issued. */ refreshTokenTtlSeconds: number; + /** Lifetime of an Account session, browser and CLI alike. */ + sessionTtlSeconds: number; /** * Present on every staging deployment. Scenario Preview is fixed client-side fixtures, so it needs * no Account configuration; `accountId` names the one Account that additionally owns the reset, @@ -298,6 +311,7 @@ export function parseServerConfig(environment: NodeJS.ProcessEnv): ServerConfig OPENTAG_OTEL_HEADERS: environment.OPENTAG_OTEL_HEADERS, OPENTAG_OTEL_SAMPLE_RATE: environment.OPENTAG_OTEL_SAMPLE_RATE, OPENTAG_REFRESH_TOKEN_TTL_SECONDS: environment.OPENTAG_REFRESH_TOKEN_TTL_SECONDS, + OPENTAG_SESSION_TTL_SECONDS: environment.OPENTAG_SESSION_TTL_SECONDS, OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID: environment.OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID, }); @@ -342,6 +356,7 @@ export function parseServerConfig(environment: NodeJS.ProcessEnv): ServerConfig port: parsed.OPENTAG_PORT, publicUrl: parsed.OPENTAG_PUBLIC_URL, refreshTokenTtlSeconds: parsed.OPENTAG_REFRESH_TOKEN_TTL_SECONDS, + sessionTtlSeconds: parsed.OPENTAG_SESSION_TTL_SECONDS, ...(parsed.OPENTAG_ENV === "staging" ? { stagingOnboardingLab: parsed.OPENTAG_STAGING_ONBOARDING_ACCOUNT_ID diff --git a/packages/server/src/db/schema/better-auth.ts b/packages/server/src/db/schema/better-auth.ts index a7798681..9a000e2c 100644 --- a/packages/server/src/db/schema/better-auth.ts +++ b/packages/server/src/db/schema/better-auth.ts @@ -47,3 +47,23 @@ export const authVerifications = pgTable( index("auth_verifications_expires_at_idx").on(table.expiresAt), ], ); + +/** + * Records that one credential the previous revision issued has been exchanged, and for which session. + * + * A stateless refresh token has nothing to consume, so without this a replay — or a second tab whose request raced the + * first — would mint another session, and every row but one would be live and invisible to the browser holding the + * cookie. The token hash is the primary key, which makes the write itself the gate: one statement decides the winner, + * so no lock is needed and no connection waits on one. + * + * The first writer wins, deliberately. Letting the last one win would mean the browser keeps whichever `Set-Cookie` + * arrives last, which is not necessarily the row that survived — a browser could be left holding a deleted session. + * + * It exists only for the compatibility window and goes when legacy credentials do. + */ +export const accountLegacyUpgrades = pgTable("account_legacy_upgrades", { + tokenHash: text("token_hash").primaryKey(), + sessionToken: text("session_token").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + ...timestamps, +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3cb21200..5f8cd449 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,14 +1,15 @@ import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import type { ProviderReadinessStatus } from "@opentag/shared"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, isNull, sql as sqlExpression } from "drizzle-orm"; import { createApp } from "./app.js"; import { createBetterAuth } from "./auth/better-auth.js"; +import { BetterAuthSessionTokens, BridgedSessionTokens } from "./auth/session-tokens.js"; import { BootstrapReadiness } from "./bootstrap-readiness.js"; import { isHostedEnvironment, parseServerConfig, serverEnvironmentSummary } from "./config.js"; import { createDatabaseClient } from "./db/client.js"; import { migrateDatabase, verifyDatabaseMigrations } from "./db/migrate.js"; -import { agents, workspaceComputers } from "./db/schema/index.js"; +import { accountLegacyUpgrades, agents, workspaceComputers } from "./db/schema/index.js"; import { createServerDiagnosticReporter, initTelemetry, shutdownTelemetry } from "./observability/index.js"; import { stopAgentSessions } from "./runtime/agent-session-stopper.js"; import { ConnectionRegistry } from "./runtime/connection-registry.js"; @@ -19,6 +20,7 @@ import { AgentService } from "./services/agents/index.js"; import { AuthIdentityService, AuthService, + type AuthTokenProvider, AuthTokenService, ConnectCodeService, DefaultGoogleIdentityClient, @@ -95,6 +97,12 @@ export { SessionService, } from "./services/sessions/index.js"; +/** The bridge is constructed before Better Auth exists; this makes the ordering mistake loud rather than silent. */ +function requireSessionTokens(tokens: AuthTokenProvider | undefined): AuthTokenProvider { + if (!tokens) throw new Error("Session tokens were used before Better Auth was constructed"); + return tokens; +} + export async function startServer(): Promise { const readiness = new BootstrapReadiness(); let app: ReturnType | undefined; @@ -125,9 +133,24 @@ export async function startServer(): Promise { const { database, sql } = createDatabaseClient(config.databaseUrl); const workspaceAdmins = new WorkspaceAdminAccess(database); + const legacyTokens = new AuthTokenService( + config.jwtSecret, + config.accessTokenTtlSeconds, + config.refreshTokenTtlSeconds, + ); + // Assigned below, once Better Auth exists; the bridge reads it lazily so the two can be constructed in either order. + let sessionTokens: AuthTokenProvider | undefined; const authService = new AuthService( database, - new AuthTokenService(config.jwtSecret, config.accessTokenTtlSeconds, config.refreshTokenTtlSeconds), + new BridgedSessionTokens( + { + issuePairForUser: (userId) => requireSessionTokens(sessionTokens).issuePairForUser(userId), + rotate: (token, userId) => requireSessionTokens(sessionTokens).rotate(token, userId), + verifyAccess: (token) => requireSessionTokens(sessionTokens).verifyAccess(token), + verifyRefresh: (token) => requireSessionTokens(sessionTokens).verifyRefresh(token), + }, + legacyTokens, + ), { workspaceAdmins }, ); const connectCodeService = new ConnectCodeService(database); @@ -250,6 +273,7 @@ export async function startServer(): Promise { }); const identityService = new AuthIdentityService(database); const postAuthentication = new PostAuthenticationService(database, workspaceAdmins); + const dev = config.devAuth ? new DevBrowserAuthService(database, config.devAuth.email) : undefined; const betterAuth = createBetterAuth(database, { onSessionCreating: async (userId) => { await postAuthentication.ensureAccountReady(userId); @@ -257,8 +281,42 @@ export async function startServer(): Promise { publicUrl: config.publicUrl, secret: config.betterAuthSecret, secureCookies: isHostedEnvironment(config.environment), + sessionTtlSeconds: config.sessionTtlSeconds, + ...(dev ? { devSignIn: () => dev.resolveUserId() } : {}), ...(config.google ? { google: config.google } : {}), + /* + * Verified against the legacy provider alone, never the bridge: this endpoint exists to retire a credential the + * previous revision issued, and a Better Auth session presented here is already what it would upgrade to. The + * live Account read is what keeps a suspended Account from refreshing its way back in. + */ + legacyUpgrade: { + resolveCredential: async (refreshToken) => { + const identity = await legacyTokens.verifyRefresh(refreshToken); + return { + expiresAt: identity.expiresAt, + userId: (await authService.getActiveUserById(identity.userId)).user.id, + }; + }, + /* + * One statement decides the winner, so a replay or a raced tab converges without a lock — and therefore + * without a connection waiting on one. The conflict branch rewrites the key with its own value: a no-op that + * exists only so `RETURNING` reports the row already there, since `DO NOTHING` returns nothing at all. + */ + recordExchange: async ({ expiresAt, sessionToken, tokenHash }) => { + const [recorded] = await database + .insert(accountLegacyUpgrades) + .values({ expiresAt, sessionToken, tokenHash }) + .onConflictDoUpdate({ + target: accountLegacyUpgrades.tokenHash, + set: { tokenHash: sqlExpression`${accountLegacyUpgrades.tokenHash}` }, + }) + .returning({ winner: accountLegacyUpgrades.sessionToken }); + if (!recorded) throw new Error("The legacy upgrade record did not return a session"); + return recorded.winner; + }, + }, }); + sessionTokens = new BetterAuthSessionTokens(betterAuth, database); const google = config.google ? new GoogleBrowserAuthService({ database, @@ -267,10 +325,16 @@ export async function startServer(): Promise { identities: identityService, postAuthentication, publicUrl: config.publicUrl, - tokenIssuer: authService, + /* + * Deliberately the legacy issuer, not the bridge. This route only ever completes a flow that started before + * this revision deployed, and it writes its result into the legacy cookies. A session token written there + * authenticates through the fallback but is invisible to `getSession`, so sign-out could not revoke it — + * a pre-cutover flow therefore finishes exactly as it would have, and that browser moves across on its next + * refresh, where the upgrade puts the replacement in Better Auth's own cookie. + */ + tokenIssuer: new AuthService(database, legacyTokens, { workspaceAdmins }), }) : undefined; - const dev = config.devAuth ? new DevBrowserAuthService(database, authService, config.devAuth.email) : undefined; const stagingOnboardingLab = config.stagingOnboardingLab ? { reset: new OnboardingResetService({ @@ -290,10 +354,11 @@ export async function startServer(): Promise { agentService, authService, browserAuth: { - dev, + devSignIn: Boolean(dev), google, publicOrigin: config.publicUrl, refreshTokenTtlSeconds: config.refreshTokenTtlSeconds, + sessionTtlSeconds: config.sessionTtlSeconds, secureCookies: isHostedEnvironment(config.environment), }, connectCode: { diff --git a/packages/server/src/plugins/user-auth.ts b/packages/server/src/plugins/user-auth.ts index b5e15aa7..9cffb598 100644 --- a/packages/server/src/plugins/user-auth.ts +++ b/packages/server/src/plugins/user-auth.ts @@ -2,9 +2,11 @@ import { fromNodeHeaders } from "better-auth/node"; import type { FastifyReply, FastifyRequest } from "fastify"; import type { OpenTagBetterAuth } from "../auth/better-auth.js"; import { + appendSetCookies, BROWSER_COOKIE_NAMES, parseCookies, requireBrowserMutationSecurity, + setBrowserCsrfCookie, } from "../services/auth/browser-cookies.js"; import { invalidCredential } from "../services/auth/errors.js"; import type { AuthenticatedUser, UserAuthService } from "../services/auth/index.js"; @@ -21,6 +23,9 @@ export interface UserAuthPreHandlerOptions { /** Present once Better Auth issues sessions; credentials it did not issue still resolve through the legacy path. */ betterAuth?: OpenTagBetterAuth; publicOrigin?: string; + secureCookies?: boolean; + /** Present with `betterAuth`; the double-submit token is renewed on this schedule so it outlasts a rolling session. */ + sessionTtlSeconds?: number; } /** @@ -34,30 +39,37 @@ export async function resolveAuthenticatedUserId( authService: UserAuthService, options: UserAuthPreHandlerOptions = {}, ): Promise { + const bearer = bearerToken(request); + if (bearer) return (await authService.getAuthenticatedUser(bearer)).me.user.id; if (options.betterAuth) { const session = await options.betterAuth.api.getSession({ headers: fromNodeHeaders(request.headers) }); // Resolved live rather than trusted from the session, so an Account suspended after issuance is rejected here // instead of at whatever authority check happens to come after the caller's side effects. if (session) return (await authService.getActiveUserById(session.user.id)).user.id; } - const authorization = request.headers.authorization; - const legacyToken = authorization?.startsWith("Bearer ") - ? authorization.slice("Bearer ".length).trim() - : parseCookies(request.headers.cookie)[BROWSER_COOKIE_NAMES.access]; - if (!legacyToken) return undefined; - const authenticated = await authService.getAuthenticatedUser(legacyToken); - return authenticated.me.user.id; + const accessCookie = parseCookies(request.headers.cookie)[BROWSER_COOKIE_NAMES.access]; + if (!accessCookie) return undefined; + return (await authService.getAuthenticatedUser(accessCookie)).me.user.id; } export function createUserAuthPreHandler(authService: UserAuthService, options: UserAuthPreHandlerOptions = {}) { - return async function userAuthPreHandler(request: FastifyRequest, _reply: FastifyReply): Promise { - const authorization = request.headers.authorization; - const bearer = authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : undefined; + return async function userAuthPreHandler(request: FastifyRequest, reply: FastifyReply): Promise { + /* + * The transport is chosen before anything authenticates, and a presented bearer authenticates as a bearer or not + * at all. Better Auth reads both the header and the cookie, and on an invalid bearer it succeeds from the cookie — + * so deciding afterwards, from the header merely being present, would let a caller holding only the HttpOnly + * session cookie attach a junk `Authorization` header and have the request treated as the CLI's: no origin check, + * no double-submit token, mutations allowed. + */ + const bearer = bearerToken(request); + if (bearer) { + request.authContext = await authService.getAuthenticatedUser(bearer); + return; + } /* - * A bearer credential carries its own proof and is used by the CLI, which has no origin to present. Cookie - * requests are browser requests, so a mutation has to additionally prove it came from this origin — but only once - * a credential has actually been presented, so a request with none still reads as unauthenticated rather than + * A cookie request is a browser request, so a mutation additionally proves it came from this origin — checked only + * once a credential has been presented, so a request with none still reads as unauthenticated rather than * forbidden. */ const requireBrowserOrigin = () => { @@ -65,15 +77,25 @@ export function createUserAuthPreHandler(authService: UserAuthService, options: if (!SAFE_METHODS.has(request.method)) requireBrowserMutationSecurity(request, options.publicOrigin); }; - /* - * Better Auth reads both its session cookie and the bearer header, so one call covers every credential it issued. - * What comes back is only an identity: suspension and Workspace grants are still resolved live from the database - * on every request, exactly as the legacy path does, so revoking either takes effect immediately. - */ if (options.betterAuth) { - const session = await options.betterAuth.api.getSession({ headers: fromNodeHeaders(request.headers) }); + /* + * Asked for its headers, not just its answer: Better Auth extends a session as it is used and reports the + * refreshed cookie this way. Taking the result alone would let the row keep moving while the browser's cookie + * expired on its original schedule — an active user signed out, with the renewed row left behind. + */ + const { headers, response: session } = await options.betterAuth.api.getSession({ + headers: fromNodeHeaders(request.headers), + returnHeaders: true, + }); if (session) { - if (!bearer) requireBrowserOrigin(); + requireBrowserOrigin(); + const renewed = headers.getSetCookie(); + if (renewed.length > 0) appendSetCookies(reply, renewed); + renewBrowserCsrfCookie(request, reply, options); + /* + * What the session carries is only an identity: suspension and Workspace grants are resolved live from the + * database on every request, so revoking either takes effect immediately. + */ request.authContext = { me: await authService.getActiveUserById(session.user.id), tokenExpiresAt: session.session.expiresAt, @@ -83,13 +105,36 @@ export function createUserAuthPreHandler(authService: UserAuthService, options: } // Credentials issued before the cutover, still valid until they expire. - if (bearer) { - request.authContext = await authService.getAuthenticatedUser(bearer); - return; - } const accessCookie = parseCookies(request.headers.cookie)[BROWSER_COOKIE_NAMES.access]; if (!accessCookie) throw invalidCredential("AUTH_INVALID_TOKEN", "Authentication is required"); requireBrowserOrigin(); request.authContext = await authService.getAuthenticatedUser(accessCookie); }; } + +/** + * Extends the double-submit token alongside the session it accompanies. + * + * Better Auth renews a session as it is used, so a browser that keeps working keeps its session but would watch this + * cookie expire on the schedule it was first issued on — leaving it authenticated and unable to mutate or sign out. + * The value is re-sent unchanged, so a tab that already read it stays correct. + */ +function renewBrowserCsrfCookie( + request: FastifyRequest, + reply: FastifyReply, + options: UserAuthPreHandlerOptions, +): void { + if (options.sessionTtlSeconds === undefined) return; + const current = parseCookies(request.headers.cookie)[BROWSER_COOKIE_NAMES.csrf]; + if (!current) return; + setBrowserCsrfCookie(reply, { + maxAgeSeconds: options.sessionTtlSeconds, + secure: options.secureCookies ?? true, + value: current, + }); +} + +function bearerToken(request: FastifyRequest): string | undefined { + const authorization = request.headers.authorization; + return authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length).trim() : undefined; +} diff --git a/packages/server/src/services/auth/auth-service.ts b/packages/server/src/services/auth/auth-service.ts index 4bd1fdee..0572165b 100644 --- a/packages/server/src/services/auth/auth-service.ts +++ b/packages/server/src/services/auth/auth-service.ts @@ -107,7 +107,9 @@ export class AuthService implements ResolvedUserTokenIssuer, UserAuthService { async refresh(refreshToken: string): Promise { const identity = await this.#authTokens.verifyRefresh(refreshToken); - return this.issueTokensForUser(identity.userId); + await this.#resolveActiveUser(identity.userId); + // Rotated rather than reissued, so the credential just replaced stops working instead of running to its own expiry. + return { ...(await this.#authTokens.rotate(refreshToken, identity.userId)), tokenType: "Bearer" }; } /** Shared post-identity boundary for connect codes and future OAuth/OIDC resolvers. */ diff --git a/packages/server/src/services/auth/browser-cookies.ts b/packages/server/src/services/auth/browser-cookies.ts index df1b3cfe..025ad23c 100644 --- a/packages/server/src/services/auth/browser-cookies.ts +++ b/packages/server/src/services/auth/browser-cookies.ts @@ -72,8 +72,12 @@ export function setBrowserSessionCookies( * A Better Auth sign-in brings its own session cookie but knows nothing about this one, and every browser mutation — * including sign-out — requires it. Without this, a session issued by Better Auth can read but never write. */ -export function setBrowserCsrfCookie(reply: FastifyReply, options: { maxAgeSeconds: number; secure: boolean }): string { - const csrf = generateSecret(24); +export function setBrowserCsrfCookie( + reply: FastifyReply, + options: { maxAgeSeconds: number; secure: boolean; value?: string }, +): string { + // An existing token is re-sent unchanged when only its lifetime is being extended; a new sign-in mints a fresh one. + const csrf = options.value ?? generateSecret(24); appendSetCookies(reply, [ cookie(BROWSER_COOKIE_NAMES.csrf, csrf, { maxAge: options.maxAgeSeconds, path: "/", secure: options.secure }), ]); @@ -81,6 +85,17 @@ export function setBrowserCsrfCookie(reply: FastifyReply, options: { maxAgeSecon } export function clearBrowserSessionCookies(reply: FastifyReply, secure: boolean): void { + clearLegacyCredentialCookies(reply, secure); + appendSetCookies(reply, [cookie(BROWSER_COOKIE_NAMES.csrf, "", { maxAge: 0, path: "/", secure })]); +} + +/** + * Retires the credentials the previous revision issued, leaving the double-submit token alone. + * + * Used when a browser is moving onto a Better Auth session rather than signing out: it is still signed in, and the + * token it needs to mutate with was just issued on the same reply. + */ +export function clearLegacyCredentialCookies(reply: FastifyReply, secure: boolean): void { appendSetCookies(reply, [ cookie(BROWSER_COOKIE_NAMES.access, "", { httpOnly: true, maxAge: 0, path: "/", secure }), cookie(BROWSER_COOKIE_NAMES.refresh, "", { @@ -89,7 +104,6 @@ export function clearBrowserSessionCookies(reply: FastifyReply, secure: boolean) path: "/api/v1/auth/browser", secure, }), - cookie(BROWSER_COOKIE_NAMES.csrf, "", { maxAge: 0, path: "/", secure }), ]); } diff --git a/packages/server/src/services/auth/dev-browser-auth.ts b/packages/server/src/services/auth/dev-browser-auth.ts index d34561c3..c4013b2c 100644 --- a/packages/server/src/services/auth/dev-browser-auth.ts +++ b/packages/server/src/services/auth/dev-browser-auth.ts @@ -1,26 +1,24 @@ -import type { RefreshTokenResponse } from "@opentag/shared"; import { sql } from "drizzle-orm"; import type { DatabaseClient } from "../../db/client.js"; import { users } from "../../db/schema/index.js"; import { AuthServiceError } from "./errors.js"; -export interface DevBrowserTokenIssuer { - issueTokensForUser(userId: string): Promise; -} - -/** Resolves an explicitly configured existing user without creating identity or Workspace records. */ +/** + * Resolves an explicitly configured existing user without creating identity or Workspace records. + * + * It answers only *who* development sign-in is for. Minting the credential belongs to Better Auth, so a development + * session is the same revocable thing every other sign-in produces. + */ export class DevBrowserAuthService { readonly #database: DatabaseClient; readonly #email: string; - readonly #tokenIssuer: DevBrowserTokenIssuer; - constructor(database: DatabaseClient, tokenIssuer: DevBrowserTokenIssuer, email: string) { + constructor(database: DatabaseClient, email: string) { this.#database = database; - this.#tokenIssuer = tokenIssuer; this.#email = email; } - async signIn(): Promise { + async resolveUserId(): Promise { const matches = await this.#database .select({ id: users.id }) .from(users) @@ -34,6 +32,6 @@ export class DevBrowserAuthService { 503, ); } - return this.#tokenIssuer.issueTokensForUser(matches[0].id); + return matches[0].id; } } diff --git a/packages/server/src/services/auth/index.ts b/packages/server/src/services/auth/index.ts index 0760592b..ae33fcc0 100644 --- a/packages/server/src/services/auth/index.ts +++ b/packages/server/src/services/auth/index.ts @@ -14,7 +14,7 @@ export { type IssuedConnectCode, issueConnectCodeInTransaction, } from "./connect-code-service.js"; -export { DevBrowserAuthService, type DevBrowserTokenIssuer } from "./dev-browser-auth.js"; +export { DevBrowserAuthService } from "./dev-browser-auth.js"; export { AuthServiceError } from "./errors.js"; export { type GoogleAuthCallbackInput, diff --git a/packages/server/src/services/auth/tokens.ts b/packages/server/src/services/auth/tokens.ts index 322072cf..55af8a92 100644 --- a/packages/server/src/services/auth/tokens.ts +++ b/packages/server/src/services/auth/tokens.ts @@ -15,6 +15,14 @@ export interface AuthTokenPair { export interface AuthTokenProvider { issuePairForUser(userId: string): Promise; + /** + * Replaces a credential the caller still holds, and withdraws the one it presented. + * + * Refresh has to be a distinct operation once credentials are rows rather than signatures: issuing without + * withdrawing leaves the presented credential valid until its own expiry, so revoking what a client currently holds + * would not lock out a copy taken before its last refresh, and every refresh would leave another live row behind. + */ + rotate(token: string, userId: string): Promise; verifyAccess(token: string): Promise; verifyRefresh(token: string): Promise; } @@ -49,6 +57,11 @@ export class AuthTokenService implements AuthTokenProvider { return { accessToken, refreshToken, expiresIn: this.accessTtlSeconds }; } + /** A signature cannot be withdrawn, so the presented pair stays valid until it expires. */ + rotate(_token: string, userId: string): Promise { + return this.issuePairForUser(userId); + } + async verifyAccess(token: string): Promise { return this.#verify(token, "access"); }